quoxcli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,242 @@
1
+ /**
2
+ * QUOXON composer text buffer: a pure reducer over { text, cursor, undo, redo,
3
+ * pastes, pasteSeq }. Written for Quox (no third-party code). The pattern is
4
+ * the usual one for Ink editors: all edits are actions, the view is derived.
5
+ *
6
+ * - cursor is a UTF-16 index that always sits on a grapheme boundary
7
+ * (Intl.Segmenter), so emoji, ZWJ sequences and combining marks move as one.
8
+ * - Large pastes collapse to `[Pasted text #N +L lines]` and expand on send.
9
+ * - Invisible Unicode (zero width, bidi controls, tag characters) is detected
10
+ * and stripped on Enter with a review round trip (see sanitizeForSend).
11
+ */
12
+
13
+ const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
14
+
15
+ export const PASTE_CHAR_LIMIT = 800;
16
+ export const PASTE_LINE_LIMIT = 3;
17
+ const UNDO_LIMIT = 100;
18
+ const PASTE_RE = /\[Pasted text #(\d+) \+\d+ lines\]/g;
19
+
20
+ export function initialBuffer(text = '') {
21
+ return { text, cursor: text.length, undo: [], redo: [], pastes: {}, pasteSeq: 0 };
22
+ }
23
+
24
+ export function graphemes(text) {
25
+ return Array.from(seg.segment(text), s => ({ index: s.index, text: s.segment }));
26
+ }
27
+
28
+ /** Boundary strictly before `idx` (0 if none). */
29
+ export function prevBoundary(text, idx) {
30
+ let prev = 0;
31
+ for (const s of seg.segment(text)) {
32
+ if (s.index >= idx) break;
33
+ prev = s.index;
34
+ }
35
+ return prev;
36
+ }
37
+
38
+ /** Boundary strictly after `idx` (text.length if none). */
39
+ export function nextBoundary(text, idx) {
40
+ for (const s of seg.segment(text)) {
41
+ if (s.index > idx) return s.index;
42
+ }
43
+ return text.length;
44
+ }
45
+
46
+ export function displayWidthOfLine(s) { return graphemes(s).length; }
47
+
48
+ /** Placeholder range under or touching `idx` when deleting in a direction. */
49
+ function placeholderAt(text, from, to) {
50
+ PASTE_RE.lastIndex = 0;
51
+ let m;
52
+ while ((m = PASTE_RE.exec(text))) {
53
+ const a = m.index, b = a + m[0].length;
54
+ if (to > a && from < b) return { a, b };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function snapshot(s) { return { text: s.text, cursor: s.cursor, pastes: s.pastes, pasteSeq: s.pasteSeq }; }
60
+
61
+ function commit(s, next) {
62
+ const undo = [...s.undo, snapshot(s)].slice(-UNDO_LIMIT);
63
+ return { ...s, ...next, undo, redo: [] };
64
+ }
65
+
66
+ export function lineCol(text, cursor) {
67
+ const before = text.slice(0, cursor);
68
+ const line = before.split('\n').length - 1;
69
+ const start = before.lastIndexOf('\n') + 1;
70
+ return { line, col: graphemes(text.slice(start, cursor)).length, start };
71
+ }
72
+
73
+ function lineStarts(text) {
74
+ const starts = [0];
75
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
76
+ return starts;
77
+ }
78
+
79
+ function indexAtLineCol(text, line, col) {
80
+ const starts = lineStarts(text);
81
+ const l = Math.max(0, Math.min(line, starts.length - 1));
82
+ const start = starts[l];
83
+ const end = l + 1 < starts.length ? starts[l + 1] - 1 : text.length;
84
+ const gs = graphemes(text.slice(start, end));
85
+ if (col >= gs.length) return end;
86
+ return start + gs[col].index;
87
+ }
88
+
89
+ export function normalizeNewlines(t) { return String(t).replace(/\r\n?/g, '\n'); }
90
+
91
+ /** Build the collapsed form of a paste, or null when it is small enough to inline. */
92
+ export function shouldCollapse(text) {
93
+ const lines = text.split('\n').length;
94
+ return text.length > PASTE_CHAR_LIMIT || lines > PASTE_LINE_LIMIT;
95
+ }
96
+
97
+ export function bufferReducer(s, a) {
98
+ switch (a.type) {
99
+ case 'insert': {
100
+ const t = normalizeNewlines(a.text);
101
+ if (!t) return s;
102
+ return commit(s, { text: s.text.slice(0, s.cursor) + t + s.text.slice(s.cursor), cursor: s.cursor + t.length });
103
+ }
104
+ case 'newline':
105
+ return bufferReducer(s, { type: 'insert', text: '\n' });
106
+ case 'paste': {
107
+ const t = normalizeNewlines(a.text);
108
+ if (!t) return s;
109
+ if (!shouldCollapse(t)) return bufferReducer(s, { type: 'insert', text: t });
110
+ const n = s.pasteSeq + 1;
111
+ const ph = `[Pasted text #${n} +${t.split('\n').length} lines]`;
112
+ return commit(s, {
113
+ text: s.text.slice(0, s.cursor) + ph + s.text.slice(s.cursor),
114
+ cursor: s.cursor + ph.length,
115
+ pastes: { ...s.pastes, [n]: t },
116
+ pasteSeq: n
117
+ });
118
+ }
119
+ case 'backspace': {
120
+ if (s.cursor === 0) return s;
121
+ const ph = placeholderAt(s.text, prevBoundary(s.text, s.cursor), s.cursor);
122
+ const from = ph ? ph.a : prevBoundary(s.text, s.cursor);
123
+ const to = ph ? Math.max(ph.b, s.cursor) : s.cursor;
124
+ return commit(s, { text: s.text.slice(0, from) + s.text.slice(to), cursor: from });
125
+ }
126
+ case 'delete': {
127
+ if (s.cursor >= s.text.length) return s;
128
+ const nb = nextBoundary(s.text, s.cursor);
129
+ const ph = placeholderAt(s.text, s.cursor, nb);
130
+ const from = ph ? Math.min(ph.a, s.cursor) : s.cursor;
131
+ const to = ph ? ph.b : nb;
132
+ return commit(s, { text: s.text.slice(0, from) + s.text.slice(to), cursor: from });
133
+ }
134
+ case 'deleteWordBack': {
135
+ let i = s.cursor;
136
+ while (i > 0 && /\s/.test(s.text[i - 1])) i--;
137
+ while (i > 0 && !/\s/.test(s.text[i - 1])) i--;
138
+ if (i === s.cursor) return s;
139
+ return commit(s, { text: s.text.slice(0, i) + s.text.slice(s.cursor), cursor: i });
140
+ }
141
+ case 'killLineBack': {
142
+ const { start } = lineCol(s.text, s.cursor);
143
+ const from = start === s.cursor && start > 0 ? start - 1 : start;
144
+ if (from === s.cursor) return s;
145
+ return commit(s, { text: s.text.slice(0, from) + s.text.slice(s.cursor), cursor: from });
146
+ }
147
+ case 'left': return { ...s, cursor: prevBoundary(s.text, s.cursor) };
148
+ case 'right': return { ...s, cursor: nextBoundary(s.text, s.cursor) };
149
+ case 'home': return { ...s, cursor: lineCol(s.text, s.cursor).start };
150
+ case 'end': {
151
+ const nl = s.text.indexOf('\n', s.cursor);
152
+ return { ...s, cursor: nl === -1 ? s.text.length : nl };
153
+ }
154
+ case 'up': case 'down': {
155
+ const { line, col } = lineCol(s.text, s.cursor);
156
+ const target = a.type === 'up' ? line - 1 : line + 1;
157
+ const count = s.text.split('\n').length;
158
+ if (target < 0 || target >= count) return s;
159
+ return { ...s, cursor: indexAtLineCol(s.text, target, col) };
160
+ }
161
+ case 'undo': {
162
+ if (!s.undo.length) return s;
163
+ const prev = s.undo[s.undo.length - 1];
164
+ return { ...s, ...prev, undo: s.undo.slice(0, -1), redo: [...s.redo, snapshot(s)] };
165
+ }
166
+ case 'redo': {
167
+ if (!s.redo.length) return s;
168
+ const next = s.redo[s.redo.length - 1];
169
+ return { ...s, ...next, redo: s.redo.slice(0, -1), undo: [...s.undo, snapshot(s)] };
170
+ }
171
+ case 'set': {
172
+ const t = normalizeNewlines(a.text ?? '');
173
+ return commit(s, { text: t, cursor: t.length, pastes: a.keepPastes ? s.pastes : {}, pasteSeq: a.keepPastes ? s.pasteSeq : 0 });
174
+ }
175
+ case 'clear':
176
+ return s.text === '' ? s : commit(s, { text: '', cursor: 0, pastes: {}, pasteSeq: 0 });
177
+ default:
178
+ return s;
179
+ }
180
+ }
181
+
182
+ /** Replace placeholders with the pasted text. */
183
+ export function expandPastes(s) {
184
+ return s.text.replace(PASTE_RE, (m, n) => (s.pastes[n] !== undefined ? s.pastes[n] : m));
185
+ }
186
+
187
+ // Invisible characters that can hide instructions in pasted text.
188
+ const INVISIBLE_RE = /[­͏؜ᅟᅠ឴឵᠋-᠏​-‏‪-‮⁠-⁤⁦-ㅤ︀-︎ᅠ\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/u;
189
+ const PICTO = /\p{Extended_Pictographic}/u;
190
+
191
+ /**
192
+ * Strip invisible characters. ZWJ (U+200D) is kept only between two
193
+ * pictographs (family/flag emoji); VS16 is kept (not in the set above).
194
+ * Returns { clean, removed: [{ code, count }], total }.
195
+ */
196
+ export function stripInvisible(text) {
197
+ const chars = Array.from(text);
198
+ const counts = new Map();
199
+ let out = '';
200
+ for (let i = 0; i < chars.length; i++) {
201
+ const ch = chars[i];
202
+ const isZwj = ch === '‍';
203
+ const keepZwj = isZwj && i > 0 && i + 1 < chars.length && PICTO.test(chars[i - 1]) && PICTO.test(chars[i + 1]);
204
+ if (!keepZwj && INVISIBLE_RE.test(ch)) {
205
+ const code = 'U+' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0');
206
+ counts.set(code, (counts.get(code) || 0) + 1);
207
+ continue;
208
+ }
209
+ out += ch;
210
+ }
211
+ const removed = [...counts].map(([code, count]) => ({ code, count }));
212
+ return { clean: out, removed, total: removed.reduce((n, r) => n + r.count, 0) };
213
+ }
214
+
215
+ /**
216
+ * Enter-time check. If anything invisible is present (in typed text or in a
217
+ * collapsed paste), return a cleaned buffer plus a note; the caller must NOT
218
+ * send, so the user reviews the cleaned text and presses Enter again.
219
+ * Otherwise returns { ok: true, text } with pastes expanded.
220
+ */
221
+ export function sanitizeForSend(s) {
222
+ const typed = stripInvisible(s.text);
223
+ const pastes = {};
224
+ let total = typed.total;
225
+ const removed = [...typed.removed];
226
+ for (const [k, v] of Object.entries(s.pastes)) {
227
+ const r = stripInvisible(v);
228
+ pastes[k] = r.clean;
229
+ total += r.total;
230
+ removed.push(...r.removed);
231
+ }
232
+ if (total === 0) return { ok: true, text: expandPastes(s) };
233
+ const merged = new Map();
234
+ for (const r of removed) merged.set(r.code, (merged.get(r.code) || 0) + r.count);
235
+ const detail = [...merged].map(([c, n]) => (n > 1 ? `${c} x${n}` : c)).join(', ');
236
+ const cursor = Math.min(s.cursor, typed.clean.length);
237
+ return {
238
+ ok: false,
239
+ buffer: { ...s, text: typed.clean, cursor, pastes },
240
+ note: `Removed ${total} invisible character${total === 1 ? '' : 's'} (${detail}). Review, then press Enter again to send.`
241
+ };
242
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Shared look: the semantic palette, NO_COLOR handling, width helpers.
3
+ *
4
+ * Every colour is a ROLE mapped to a shade of the Q glyph (sky #38bdf8 -> indigo #6366f1 -> violet #a855f7,
5
+ * plus teal #06b6d4) or a state colour. Two tables: DARK (checked >= 4.5:1 against #000) and LIGHT
6
+ * (checked >= 4.5:1 against #fff), chosen by the terminal background (COLORFGBG when set, else dark).
7
+ * Each entry carries a 256-colour fallback used when the terminal is a 256-colour TTY without truecolor.
8
+ * Purple is for keys and accents only, never whole sentences.
9
+ */
10
+ import { graphemes } from "./textBuffer.js";
11
+
12
+ export const MIN_COLS = 40;
13
+
14
+ /** role -> [truecolor hex, ansi256 index]. */
15
+ export const DARK = {
16
+ heading: ['#38bdf8', 75], // first stop; the word is painted with HEADING_STOPS
17
+ icon: ['#06b6d4', 38],
18
+ bullet1: ['#38bdf8', 75], bullet2: ['#818cf8', 105], bullet3: ['#a855f7', 135],
19
+ label: ['#a5b4fc', 147],
20
+ value: ['#f1f5f9', 255],
21
+ number: ['#38bdf8', 75],
22
+ link: ['#38bdf8', 75],
23
+ code: ['#06b6d4', 38], codeBg: ['#1f2937', 235], codeRule: ['#155e75', 24],
24
+ key: ['#a855f7', 135],
25
+ muted: ['#7d8594', 245],
26
+ ok: ['#4ade80', 78], warn: ['#f59e0b', 214], error: ['#f87171', 210],
27
+ // legacy names, kept so existing call sites read the same
28
+ sky: ['#38bdf8', 75], violet: ['#a855f7', 135], indigo: ['#818cf8', 105], teal: ['#06b6d4', 38],
29
+ fail: ['#f87171', 210], dim: ['#7d8594', 245], faint: ['#4b5563', 240],
30
+ text: ['#cbd5e1', 252], input: ['#ffffff', 231], placeholder: ['#9ca3af', 248]
31
+ };
32
+ export const LIGHT = {
33
+ heading: ['#0369a1', 25],
34
+ icon: ['#0e7490', 30],
35
+ bullet1: ['#0369a1', 25], bullet2: ['#4338ca', 62], bullet3: ['#7e22ce', 91],
36
+ label: ['#4f46e5', 62],
37
+ value: ['#0f172a', 234],
38
+ number: ['#0369a1', 25],
39
+ link: ['#0369a1', 25],
40
+ code: ['#0e7490', 30], codeBg: ['#ecfeff', 195], codeRule: ['#67e8f9', 117],
41
+ key: ['#7e22ce', 91],
42
+ muted: ['#4b5563', 240],
43
+ ok: ['#15803d', 28], warn: ['#b45309', 130], error: ['#b91c1c', 124],
44
+ sky: ['#0369a1', 25], violet: ['#7e22ce', 91], indigo: ['#4338ca', 62], teal: ['#0e7490', 30],
45
+ fail: ['#b91c1c', 124], dim: ['#4b5563', 240], faint: ['#9ca3af', 248],
46
+ text: ['#334155', 238], input: ['#0f172a', 234], placeholder: ['#6b7280', 243]
47
+ };
48
+ /** Roles a reader must be able to read: each is held to >= 4.5:1 on its background (tests). */
49
+ export const ROLES = ['heading', 'icon', 'bullet1', 'bullet2', 'bullet3', 'label', 'value', 'number', 'link', 'code', 'key', 'muted', 'ok', 'warn', 'error', 'text', 'placeholder'];
50
+
51
+ /** Heading gradient stops across the word: sky -> indigo -> violet (light: the -700 shades). */
52
+ export const HEADING_STOPS = { dark: ['#38bdf8', '#6366f1', '#a855f7'], light: ['#0369a1', '#4338ca', '#7e22ce'] };
53
+ /** Agent names get a stable shade of the Q by hash. */
54
+ export const AGENT_SHADES = {
55
+ dark: ['#38bdf8', '#a855f7', '#2dd4bf', '#818cf8', '#c4b5fd', '#7dd3fc'],
56
+ light: ['#0369a1', '#7e22ce', '#0f766e', '#4338ca', '#6d28d9', '#075985']
57
+ };
58
+
59
+ /** Model providers in their brand colours (dark, light). Unknown = muted. */
60
+ export const PROVIDER_COLORS = {
61
+ openai: ['#f5f5f5', '#111111'], anthropic: ['#D97757', '#b4532e'], google: ['#4796E3', '#1a62c4'],
62
+ ollama: ['#e5e7eb', '#4b5563'], mistral: ['#FA520F', '#c2410c'], deepseek: ['#4D6BFE', '#2f4fe0'],
63
+ perplexity: ['#20808D', '#1a6b76'], xai: ['#f5f5f5', '#111111'],
64
+ // B5: OpenAI-compatible presets (dark bg shade, light bg shade; all >= 4.5:1 on their background)
65
+ openrouter: ['#6566F1', '#4f46e5'], huggingface: ['#FFD21E', '#8a6500'], groq: ['#F55036', '#c2290f'],
66
+ together: ['#0F6FFF', '#0b57d0'], lmstudio: ['#a78bfa', '#6d28d9'], vllm: ['#30A2FF', '#0b6bb8'],
67
+ openai_compatible: ['#9ca3af', '#4b5563']
68
+ };
69
+ /** Protocol trace marks (AEE, AOCL, VOLT, WARD), each its own Q shade (dark, light). */
70
+ export const PROTO_COLORS = {
71
+ aee: ['#38bdf8', '#0369a1'], aocl: ['#818cf8', '#4338ca'], volt: ['#2dd4bf', '#0f766e'], ward: ['#a855f7', '#7e22ce']
72
+ };
73
+
74
+ /** Back-compat: the dark truecolor table as name -> hex. */
75
+ export const COLORS = Object.fromEntries(Object.entries(DARK).map(([k, v]) => [k, v[0]]));
76
+ export const MODE_COLOR = { ask: COLORS.sky, auto: COLORS.warn, plan: COLORS.violet, demo: COLORS.violet };
77
+
78
+ export const mono = () => Boolean(process.env.NO_COLOR);
79
+
80
+ /** Light background? COLORFGBG is "fg;bg" (sometimes "fg;x;bg"); bg 7 or 9..15 is light. Unknown = dark. */
81
+ export function isLight(env = process.env) {
82
+ if (env.QUOX_THEME === 'light') return true;
83
+ if (env.QUOX_THEME === 'dark') return false;
84
+ const parts = String(env.COLORFGBG || '').split(';');
85
+ const bg = Number(parts[parts.length - 1]);
86
+ if (!env.COLORFGBG || !Number.isFinite(bg)) return false;
87
+ return bg === 7 || (bg >= 9 && bg <= 15);
88
+ }
89
+ export const table = (env = process.env) => (isLight(env) ? LIGHT : DARK);
90
+
91
+ /** 256-colour terminal (a TTY that says 256color and not truecolor): use the explicit fallback index. */
92
+ export function use256(env = process.env, isTTY = Boolean(process.stdout?.isTTY)) {
93
+ if (!isTTY) return false;
94
+ if (/^(truecolor|24bit)$/i.test(env.COLORTERM || '')) return false;
95
+ return /256/.test(env.TERM || '') || env.FORCE_COLOR === '2';
96
+ }
97
+
98
+ /** Role or legacy name -> an Ink colour string; anything else passes through. undefined under NO_COLOR.
99
+ * `provider:<id>` and `proto:<name>` resolve through providerColor / proto. */
100
+ export const c = (name) => {
101
+ if (mono()) return undefined;
102
+ if (typeof name === 'string' && name.startsWith('provider:')) return providerColor(name.slice(9));
103
+ if (typeof name === 'string' && name.startsWith('proto:')) return proto(name.slice(6));
104
+ const e = table()[name];
105
+ if (!e) return name;
106
+ return use256() ? `ansi256(${e[1]})` : e[0];
107
+ };
108
+
109
+ export function clampCols(n) { return Math.max(MIN_COLS, Number.isFinite(n) ? Math.floor(n) : 80); }
110
+
111
+ export const clip = (s, max) => {
112
+ const gs = graphemes(String(s ?? '').replace(/\s+/g, ' '));
113
+ if (max <= 0) return '';
114
+ return gs.length > max ? gs.slice(0, max - 1).map(g => g.text).join('') + '…' : gs.map(g => g.text).join('');
115
+ };
116
+
117
+ const hex2rgb = (hex) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
118
+ /** sRGB relative luminance and WCAG contrast. */
119
+ export function luminance(hex) {
120
+ const [r, g, b] = hex2rgb(hex).map(v => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
121
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
122
+ }
123
+ export function contrast(a, b) {
124
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
125
+ return (x + 0.05) / (y + 0.05);
126
+ }
127
+
128
+ /** Per-character gradient colours for a heading (empty array under NO_COLOR). */
129
+ export function gradient(text, stops = HEADING_STOPS[isLight() ? 'light' : 'dark']) {
130
+ const chars = [...String(text)];
131
+ if (mono()) return chars.map(ch => ({ ch, color: undefined }));
132
+ const rgb = stops.map(hex2rgb);
133
+ return chars.map((ch, i) => {
134
+ const k = chars.length > 1 ? i / (chars.length - 1) : 0;
135
+ const seg = Math.min(rgb.length - 2, Math.floor(k * (rgb.length - 1)));
136
+ const f = k * (rgb.length - 1) - seg;
137
+ const col = rgb[seg].map((v, j) => Math.round(v + (rgb[seg + 1][j] - v) * f));
138
+ return { ch, color: use256() ? stops[Math.round(k * (stops.length - 1))] : `#${col.map(v => v.toString(16).padStart(2, '0')).join('')}` };
139
+ });
140
+ }
141
+
142
+ /** Stable shade for an agent name (SEER, LUCID, VULCAN ... stay distinguishable). */
143
+ export function agentColor(name) {
144
+ if (mono()) return undefined;
145
+ const shades = AGENT_SHADES[isLight() ? 'light' : 'dark'];
146
+ let x = 5381;
147
+ for (const ch of String(name || '').toUpperCase()) x = ((x * 33) ^ ch.codePointAt(0)) >>> 0;
148
+ return shades[x % shades.length];
149
+ }
150
+
151
+ /** Provider id from a provider and/or model name (turn.started.provider/model, vault types, display names). */
152
+ export function providerOf(provider, model) {
153
+ const p = String(provider || '').toLowerCase().replace(/[\s_-]*(api|subscription)$/, '').trim();
154
+ const m = String(model || '').toLowerCase();
155
+ const DIRECT = { openai: 'openai', chatgpt: 'openai', gpt: 'openai', anthropic: 'anthropic', claude: 'anthropic', google: 'google', gemini: 'google',
156
+ ollama: 'ollama', local: 'ollama', mistral: 'mistral', deepseek: 'deepseek', perplexity: 'perplexity', xai: 'xai', grok: 'xai',
157
+ openrouter: 'openrouter', huggingface: 'huggingface', hf: 'huggingface', groq: 'groq', together: 'together', lmstudio: 'lmstudio', 'lm studio': 'lmstudio',
158
+ 'lm-studio': 'lmstudio', vllm: 'vllm', custom: 'openai_compatible', openai_compatible: 'openai_compatible', 'openai-compatible': 'openai_compatible' };
159
+ if (DIRECT[p]) return DIRECT[p];
160
+ const x = m || p;
161
+ if (/^(gpt-|chatgpt|o[134](-|$)|openai)/.test(x)) return 'openai';
162
+ if (/^claude|^anthropic/.test(x)) return 'anthropic';
163
+ if (/^gemini|^google/.test(x)) return 'google';
164
+ if (/^ollama[:/]|^ollama$/.test(x)) return 'ollama';
165
+ if (/^(mistral|codestral|mixtral|magistral|devstral)/.test(x)) return 'mistral';
166
+ if (/^deepseek/.test(x)) return 'deepseek';
167
+ if (/^(sonar|perplexity|pplx)/.test(x)) return 'perplexity';
168
+ if (/^(grok|xai)/.test(x)) return 'xai';
169
+ return null;
170
+ }
171
+ /** Brand colour for a provider/model; unknown = muted; undefined under NO_COLOR. */
172
+ export function providerColor(provider, model) {
173
+ if (mono()) return undefined;
174
+ const id = providerOf(provider, model);
175
+ if (!id) return c('muted');
176
+ return PROVIDER_COLORS[id][isLight() ? 1 : 0];
177
+ }
178
+ /** Colour for a protocol trace mark: aee sky, aocl indigo, volt teal, ward violet; unknown = muted. */
179
+ export function proto(name) {
180
+ if (mono()) return undefined;
181
+ const e = PROTO_COLORS[String(name || '').toLowerCase()];
182
+ return e ? e[isLight() ? 1 : 0] : c('muted');
183
+ }
184
+
185
+ /** Bullet colour by list depth (0-based): sky, indigo, violet, cycling. */
186
+ export const bulletRole = (depth) => ['bullet1', 'bullet2', 'bullet3'][((depth % 3) + 3) % 3];
187
+
188
+ // ---- paint: split a plain line into coloured runs; the characters never change ---------------------
189
+ const GLYPH_ROLE = {
190
+ '●': 'ok', '✔': 'ok', '◐': 'warn', '○': 'muted', '?': 'muted', '✖': 'error', '⚠': 'warn', '⊘': 'warn',
191
+ '◉': 'icon', 'Ⓠ': 'icon', '▸': 'key', '⛔': 'warn', '↳': 'icon', '»': 'muted', '·': 'muted', '⬢': 'icon', '⬡': 'muted'
192
+ };
193
+ const KEYS = '(?:(?:Ctrl|Shift|Alt|Cmd|Option)\\+\\S+|Enter|Tab|Esc|Up|Down|Left|Right|Space|Backspace|PgUp|PgDn)';
194
+ const TOKEN = new RegExp([
195
+ '(https?:\\/\\/[^\\s)]+)', // 1 link
196
+ '(\\bquox(?: (?![·])[^\\s·,;)]+)+)', // 2 a quox command
197
+ '((?<![\\w/])\\/[a-z][\\w-]*)', // 3 slash command
198
+ `((?<![\\w+])${KEYS}(?![\\w+]))`, // 4 keyboard key
199
+ '((?<![\\w<])<[^>\\s]+>|\\[[^\\]\\s]+\\])', // 5 placeholder <name> or [args]
200
+ '( · )', // 6 separator
201
+ '((?<![\\w/.-])(?:(?:gpt|claude|gemini|o[134]|mistral|codestral|deepseek|sonar|grok)-[\\w.:-]*\\w|ollama:[\\w.:-]*\\w|OpenAI|openai|Anthropic|anthropic|Claude(?: API)?|Gemini|Ollama|ollama|Mistral|DeepSeek|Perplexity|Grok|xAI)(?![\\w-]))', // 7 provider/model
202
+ '((?<![\\w.#-])\\d+(?:[.,:-]\\d+)*(?:%|ms|s|k|m|h|d|x|KB|MB|GB)?(?![\\w]))' // 8 number
203
+ ].join('|'), 'g');
204
+ const ROLE_OF = [null, 'link', 'code', 'key', 'key', 'muted', 'muted', 'provider', 'number'];
205
+ const LETTER_KEY = /^([a-z])(?= (?:approve|deny|open|stop|peek|confirm|edit|retry|cancel|copy|quit)\b)/;
206
+
207
+ /** Runs of `text` with roles; `base` colours the prose between tokens. Pure. */
208
+ export function paintRuns(text, base) {
209
+ const out = [];
210
+ const push = (t, role) => { if (!t) return; const last = out[out.length - 1]; if (last && last.role === role) last.text += t; else out.push({ text: t, role }); };
211
+ const s = String(text ?? '');
212
+ let i = 0;
213
+ const startsSeg = (at) => at === 0 || s.slice(0, at).endsWith(' · ') || /^\s*$/.test(s.slice(0, at));
214
+ for (const m of s.matchAll(TOKEN)) {
215
+ proseRuns(s.slice(i, m.index), base, push, startsSeg(i));
216
+ const g = m.findIndex((x, k) => k > 0 && x !== undefined);
217
+ push(m[0], ROLE_OF[g] === 'provider' ? `provider:${providerOf(m[0], m[0])}` : ROLE_OF[g]);
218
+ i = m.index + m[0].length;
219
+ }
220
+ proseRuns(s.slice(i), base, push, startsSeg(i));
221
+ return out;
222
+ }
223
+ function proseRuns(seg, base, push, segStart) {
224
+ const k = segStart && seg.match(LETTER_KEY);
225
+ if (k) { push(k[1], 'key'); seg = seg.slice(1); }
226
+ push(seg, base);
227
+ }
228
+
229
+ /**
230
+ * A note/list line as runs: a leading state glyph, then `Label value` (two spaces) or `label: value`,
231
+ * then tokens. tone: undefined/'dim' (neutral), 'ok', 'warn', 'accent', 'error'.
232
+ */
233
+ export function paintLine(text, tone) {
234
+ const s = String(text ?? '');
235
+ const out = [];
236
+ const neutral = !tone || tone === 'dim';
237
+ const prose = tone === 'warn' ? 'warn' : tone === 'error' || tone === 'fail' ? 'error' : tone === 'accent' ? 'sky' : null;
238
+ const m = s.match(/^(\s*)(?:([●✔◐○?✖⚠⊘◉Ⓠ▸⛔↳»·⬢⬡])( ))?(.*)$/u);
239
+ const [, lead, glyph, gsp, rest0] = m;
240
+ if (lead) out.push({ text: lead, role: undefined });
241
+ if (glyph) out.push({ text: glyph, role: tone === 'warn' && glyph !== '○' ? 'warn' : GLYPH_ROLE[glyph] }, { text: gsp, role: undefined });
242
+ let rest = rest0;
243
+ const lv = rest.match(/^(\S[^·]{0,30}?\S|\S)( {2,})(\S.*)$/) || rest.match(/^([A-Za-z][\w .-]{0,22}:)( )(\S.*)$/);
244
+ let valueBase = prose || (tone === 'ok' && !glyph ? 'ok' : neutral && !lv && !glyph ? 'muted' : 'text');
245
+ if (lv && !/^https?:/.test(lv[1])) {
246
+ const pv = providerOf(lv[1].replace(/\s*\(.*\)$/, ''), null);
247
+ out.push({ text: lv[1], role: pv ? `provider:${pv}` : 'label' }, { text: lv[2], role: undefined });
248
+ rest = lv[3];
249
+ if (!prose) valueBase = 'value';
250
+ }
251
+ out.push(...paintRuns(rest, valueBase));
252
+ return out;
253
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Dev transcript capture (opt-in, off by default). One JSONL line per client event so a local
3
+ * review loop can analyse sessions without the owner pasting terminal output.
4
+ *
5
+ * On when QUOX_DEV_TRANSCRIPT=1 or the flag file ~/.config/quox/dev-transcripts exists;
6
+ * QUOX_DEV_TRANSCRIPT=0 forces it off. Fields are ALLOWLISTED per event: objects are never
7
+ * dumped wholesale, and every string passes a token scrubber as a second line of defence.
8
+ */
9
+ import { mkdirSync, openSync, writeSync, closeSync, existsSync, chmodSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { homedir } from 'node:os';
12
+
13
+ export const MAX_FIELD = 4000;
14
+ export const SILENT_GAP_MS = 10000;
15
+
16
+ export function transcriptEnabled({ env = process.env, home = homedir(), exists = existsSync } = {}) {
17
+ if (env.QUOX_DEV_TRANSCRIPT === '0') return false;
18
+ if (env.QUOX_DEV_TRANSCRIPT === '1') return true;
19
+ return exists(join(home, '.config', 'quox', 'dev-transcripts'));
20
+ }
21
+
22
+ const SCRUBS = [
23
+ [/eyJ[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]+)*/g, '[redacted-jwt]'],
24
+ [/\bBearer\s+[^\s"']+/gi, 'Bearer [redacted]'],
25
+ [/\bsk-[A-Za-z0-9_-]{8,}/g, '[redacted-key]'],
26
+ [/\b(?:ghp|gho|ghs|xox[abp]|qsk|AKIA)[A-Za-z0-9_-]{12,}/g, '[redacted-key]'],
27
+ [/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|service[_-]?key|secret|password|passwd|authorization)["']?\s*[:=]\s*["']?)[^\s"',}]+/gi, '$1[redacted]']
28
+ ];
29
+
30
+ export function scrub(s) {
31
+ let out = String(s);
32
+ for (const [re, rep] of SCRUBS) out = out.replace(re, rep);
33
+ return out;
34
+ }
35
+
36
+ /** Strings are scrubbed and truncated; non-strings become a scrubbed JSON string (never a raw object). */
37
+ export function clean(v) {
38
+ if (v == null || typeof v === 'number' || typeof v === 'boolean') return v;
39
+ let s = typeof v === 'string' ? v : (() => { try { return JSON.stringify(v); } catch { return String(v); } })();
40
+ s = scrub(s);
41
+ return s.length > MAX_FIELD ? `${s.slice(0, MAX_FIELD)}…[truncated ${s.length - MAX_FIELD} chars]` : s;
42
+ }
43
+
44
+ // SSE event fields worth keeping, by name. Anything not listed is dropped.
45
+ const SSE_FIELDS = ['type', 'agent', 'model', 'provider', 'hat', 'conversation_id', 'session_id', 'stage', 'message',
46
+ 'content', 'call_id', 'tool', 'summary', 'status', 'result_summary', 'duration_ms', 'envelope_id', 'target', 'scope',
47
+ 'risk', 'describable', 'dashboard_url', 'inbox_item_id', 'approval_id', 'decision', 'text', 'protocol', 'label', 'ref',
48
+ 'detail', 'error', 'full_response'];
49
+ const RECEIPT_FIELDS = ['verified', 'ward_seq'];
50
+ // Metering only (numbers, never strings): lets a local eval loop sum the cost of interactive turns.
51
+ const USAGE_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
52
+ const num = (v) => typeof v === 'number' && Number.isFinite(v);
53
+
54
+ export function pickSse(ev) {
55
+ const o = {};
56
+ if (!ev || typeof ev !== 'object') return o;
57
+ for (const k of SSE_FIELDS) if (ev[k] !== undefined) o[k] = clean(ev[k]);
58
+ if (ev.receipt && typeof ev.receipt === 'object') {
59
+ o.receipt = {};
60
+ for (const k of RECEIPT_FIELDS) if (ev.receipt[k] !== undefined) o.receipt[k] = clean(ev.receipt[k]);
61
+ }
62
+ if (num(ev.cost_usd)) o.cost_usd = ev.cost_usd;
63
+ if (ev.usage && typeof ev.usage === 'object') {
64
+ const u = {};
65
+ for (const k of USAGE_FIELDS) if (num(ev.usage[k])) u[k] = ev.usage[k];
66
+ if (Object.keys(u).length) o.usage = u;
67
+ }
68
+ return o;
69
+ }
70
+
71
+ /** Map one app dispatch action to a transcript record (or null to skip). */
72
+ export function recordFor(a) {
73
+ switch (a?.type) {
74
+ case 'event': return { t: 'sse', ...pickSse(a.ev) };
75
+ case 'user': return { t: 'user', mode: a.queued ? 'queue-drain' : 'send', text: clean(a.text) };
76
+ case 'steer.add': return { t: 'user', mode: 'steer', id: clean(a.id), text: clean(a.text) };
77
+ case 'queue.add': return { t: 'user', mode: 'queue', text: clean(a.text) };
78
+ case 'steer.failed': case 'steer.converted': return { t: 'notice', kind: a.type, id: clean(a.id), text: clean(a.reason) };
79
+ case 'note': return { t: 'notice', kind: 'note', tone: clean(a.tone), text: clean(a.text) };
80
+ case 'error': return { t: 'error', text: clean(a.text) };
81
+ case 'notify': return { t: 'notify', approvals: a.patch?.approvals ?? null, error: clean(a.patch?.error ?? null) };
82
+ case 'turn.begin': return { t: 'turn.begin', turnId: clean(a.turnId) };
83
+ case 'approval.sent': return { t: 'approval.sent', action: clean(a.action), callId: clean(a.callId), tool: clean(a.tool), target: clean(a.target) };
84
+ case 'approval.result': return { t: 'approval.result', callId: clean(a.callId), outcome: clean(a.outcome), text: clean(a.text ?? null) };
85
+ case 'verbose': return { t: 'verbose', mode: clean(a.mode) };
86
+ case 'ctx': return { t: 'ctx', conversationId: clean(a.ctx?.conversationId ?? null) };
87
+ // /connect: provider and last 4 only, by construction (the key is never in an action).
88
+ case 'connect.started': return { t: 'connect.started', provider: clean(a.provider) };
89
+ case 'connect.saved': return { t: 'connect.saved', provider: clean(a.provider), last4: clean(a.last4) };
90
+ case 'inbox.sync': return null; // every 30 s, ids only: noise
91
+ default: return { t: 'action', type: clean(a?.type) };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * -> null when disabled. Otherwise { action(a), command(line), resize(cols, rows), end(reason), path }.
97
+ * Never throws into the app: a write failure disables capture for the rest of the session.
98
+ */
99
+ export function createTranscript({ env = process.env, home = homedir(), now = Date.now, exists = existsSync, meta = {} } = {}) {
100
+ if (!transcriptEnabled({ env, home, exists })) return null;
101
+ const dir = env.QUOX_DEV_TRANSCRIPT_DIR || join(home, '.local', 'share', 'quox', 'transcripts');
102
+ let fd = null;
103
+ const start = now();
104
+ const path = join(dir, `${new Date(start).toISOString().replace(/[:.]/g, '-')}-${process.pid}.jsonl`);
105
+ try {
106
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
107
+ try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
108
+ fd = openSync(path, 'a', 0o600);
109
+ chmodSync(path, 0o600);
110
+ } catch { return null; }
111
+ const turn = { id: null, t0: 0, firstText: false, lastSse: 0 };
112
+ const write = (rec) => {
113
+ if (fd == null || !rec) return;
114
+ try { writeSync(fd, JSON.stringify({ ts: new Date(now()).toISOString(), ...rec }) + '\n'); }
115
+ catch { try { closeSync(fd); } catch { /* ignore */ } fd = null; }
116
+ };
117
+ write({ t: 'start', version: clean(meta.version ?? null), cols: meta.cols ?? null, rows: meta.rows ?? null,
118
+ profile: clean(meta.profile ?? null), org: clean(meta.org ?? null), cwd: clean(meta.cwd ?? null), pid: process.pid });
119
+
120
+ const action = (a) => {
121
+ const t = now();
122
+ const rec = recordFor(a);
123
+ if (a?.type === 'turn.begin') Object.assign(turn, { id: a.turnId, t0: t, firstText: false, lastSse: t });
124
+ if (a?.type === 'event' && turn.id) {
125
+ if (t - turn.lastSse > SILENT_GAP_MS) write({ t: 'gap', turnId: turn.id, ms: t - turn.lastSse, before: clean(a.ev?.type) });
126
+ turn.lastSse = t;
127
+ }
128
+ write(rec);
129
+ if (a?.type === 'event' && turn.id) {
130
+ const ty = a.ev?.type;
131
+ if (!turn.firstText && (ty === 'chunk' || ty === 'text.delta') && a.ev?.content) {
132
+ turn.firstText = true;
133
+ write({ t: 'timing', turnId: turn.id, first_text_ms: t - turn.t0 });
134
+ }
135
+ if (ty === 'turn.finished') {
136
+ write({ t: 'timing', turnId: turn.id, finish_ms: t - turn.t0, status: clean(a.ev?.status ?? null), saw_text: turn.firstText });
137
+ turn.id = null;
138
+ }
139
+ }
140
+ };
141
+ return {
142
+ path,
143
+ action,
144
+ command: (line) => { const [cmd, ...rest] = String(line).trim().split(/\s+/); write({ t: 'command', cmd: clean(cmd), arg: clean(rest.join(' ') || null) }); },
145
+ // Owner observation (/note): recorded verbatim (scrubbed, 2000 chars), never sent anywhere else.
146
+ note: (text) => write({ t: 'note', text: clean(String(text).slice(0, 2000)), ts: new Date(now()).toISOString() }),
147
+ resize: (cols, rows) => write({ t: 'resize', cols: cols ?? null, rows: rows ?? null }),
148
+ end: (reason = 'exit') => { write({ t: 'end', reason: clean(reason), duration_ms: now() - start }); if (fd != null) { try { closeSync(fd); } catch { /* ignore */ } fd = null; } }
149
+ };
150
+ }