manigent-server 0.1.0__py3-none-any.whl

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 (1004) hide show
  1. manigent/_catalog/mcps/chess/README.md +38 -0
  2. manigent/_catalog/mcps/chess/mcp.json +9 -0
  3. manigent/_catalog/mcps/registry.json +9 -0
  4. manigent/_catalog/skills/brief/SKILL.md +339 -0
  5. manigent/_catalog/skills/broaden-options/SKILL.md +133 -0
  6. manigent/_catalog/skills/calendar/SKILL.md +44 -0
  7. manigent/_catalog/skills/calendar/starter_config.jsonc +25 -0
  8. manigent/_catalog/skills/gmail/SKILL.md +73 -0
  9. manigent/_catalog/skills/google-calendar/SKILL.md +44 -0
  10. manigent/_catalog/skills/google-drive/SKILL.md +42 -0
  11. manigent/_catalog/skills/google-drive-write/SKILL.md +45 -0
  12. manigent/_catalog/skills/google-drive-write/starter_config.jsonc +45 -0
  13. manigent/_catalog/skills/grill-me/SKILL.md +173 -0
  14. manigent/_catalog/skills/homekit/SKILL.md +37 -0
  15. manigent/_catalog/skills/http-api/SKILL.md +117 -0
  16. manigent/_catalog/skills/ib/SKILL.md +89 -0
  17. manigent/_catalog/skills/ib/starter_config.jsonc +35 -0
  18. manigent/_catalog/skills/mailbox/SKILL.md +47 -0
  19. manigent/_catalog/skills/mailbox/starter_config.jsonc +38 -0
  20. manigent/_catalog/skills/media/SKILL.md +93 -0
  21. manigent/_catalog/skills/media/starter_config.jsonc +63 -0
  22. manigent/_catalog/skills/osxphotos/SKILL.md +41 -0
  23. manigent/_catalog/skills/photo-story/SKILL.md +285 -0
  24. manigent/_catalog/skills/playwright-browser/SKILL.md +291 -0
  25. manigent/_catalog/skills/playwright-browser/starter_config.jsonc +44 -0
  26. manigent/_catalog/skills/playwright-save-skill/SKILL.md +149 -0
  27. manigent/_catalog/skills/registry.json +166 -0
  28. manigent/_catalog/skills/web-artifacts/SKILL.md +194 -0
  29. manigent/api/action_executor.py +404 -0
  30. manigent/api/action_validator.py +531 -0
  31. manigent/api/actions.py +221 -0
  32. manigent/api/client.py +558 -0
  33. manigent/api/control.py +806 -0
  34. manigent/api/edge_protocol.py +123 -0
  35. manigent/api/fs_protocol.py +450 -0
  36. manigent/api/host_protocol.py +298 -0
  37. manigent/api/managed_job_client.py +124 -0
  38. manigent/api/responses.py +342 -0
  39. manigent/api/retry_transport.py +178 -0
  40. manigent/api/turn_protocol.py +78 -0
  41. manigent/cli_lifecycle.py +467 -0
  42. manigent/cli_server.py +978 -0
  43. manigent/doctor.py +1167 -0
  44. manigent/hub_protocol/canonical.py +375 -0
  45. manigent/hub_protocol/codes.py +171 -0
  46. manigent/hub_protocol/constants.py +71 -0
  47. manigent/hub_protocol/envelope.py +218 -0
  48. manigent/hub_protocol/frames.py +788 -0
  49. manigent/hub_protocol/http.py +260 -0
  50. manigent/hub_protocol/ids.py +170 -0
  51. manigent/hub_protocol/keys.py +195 -0
  52. manigent/hub_protocol/text.py +75 -0
  53. manigent/llm/base.py +1072 -0
  54. manigent/llm/prompt_builder.py +2450 -0
  55. manigent/llm/triggers.py +68 -0
  56. manigent/models/agent.py +2905 -0
  57. manigent/models/agent_bundle.py +298 -0
  58. manigent/models/brief_tab.py +311 -0
  59. manigent/models/chat_message.py +231 -0
  60. manigent/models/chatroom.py +1108 -0
  61. manigent/models/context.py +1159 -0
  62. manigent/models/desk_label.py +836 -0
  63. manigent/models/desk_read_state.py +220 -0
  64. manigent/models/desk_sign_off.py +267 -0
  65. manigent/models/desk_sop.py +965 -0
  66. manigent/models/desk_todo.py +1603 -0
  67. manigent/models/desk_todo_link.py +404 -0
  68. manigent/models/file_cache.py +75 -0
  69. manigent/models/host.py +732 -0
  70. manigent/models/install_token.py +401 -0
  71. manigent/models/invitation_code.py +162 -0
  72. manigent/models/knowledge_pack.py +552 -0
  73. manigent/models/managed_job.py +295 -0
  74. manigent/models/model_config.py +564 -0
  75. manigent/models/model_defaults.py +238 -0
  76. manigent/models/onboarding_plan.py +161 -0
  77. manigent/models/participant.py +449 -0
  78. manigent/models/persistable.py +892 -0
  79. manigent/models/plan_markdown.py +1774 -0
  80. manigent/models/project.py +1343 -0
  81. manigent/models/project_plan.py +8104 -0
  82. manigent/models/project_report.py +145 -0
  83. manigent/models/provenance.py +261 -0
  84. manigent/models/provider_agents.py +101 -0
  85. manigent/models/public_request.py +108 -0
  86. manigent/models/reflection_schedule.py +191 -0
  87. manigent/models/scheduled_message.py +285 -0
  88. manigent/models/share_token.py +190 -0
  89. manigent/models/skill_version.py +31 -0
  90. manigent/models/sop_template.py +187 -0
  91. manigent/models/thinking_partner_plan.py +187 -0
  92. manigent/models/tunnel_binding.py +129 -0
  93. manigent/models/user.py +1276 -0
  94. manigent/models/work_tracker.py +510 -0
  95. manigent/runner/invocation_registry.py +187 -0
  96. manigent/server/agent_capabilities.py +119 -0
  97. manigent/server/agent_visibility.py +119 -0
  98. manigent/server/announcements.py +119 -0
  99. manigent/server/app.py +807 -0
  100. manigent/server/auth.py +355 -0
  101. manigent/server/batch_timeout.py +94 -0
  102. manigent/server/channels/access.py +194 -0
  103. manigent/server/channels/adapter.py +295 -0
  104. manigent/server/channels/config.py +693 -0
  105. manigent/server/channels/contacts.py +39 -0
  106. manigent/server/channels/email_adapter.py +436 -0
  107. manigent/server/channels/email_address.py +145 -0
  108. manigent/server/channels/email_form.py +242 -0
  109. manigent/server/channels/email_format.py +285 -0
  110. manigent/server/channels/email_mail.py +427 -0
  111. manigent/server/channels/email_mailgun.py +164 -0
  112. manigent/server/channels/email_text.py +280 -0
  113. manigent/server/channels/file_links.py +127 -0
  114. manigent/server/channels/gateway.py +2998 -0
  115. manigent/server/channels/hub_delivery.py +211 -0
  116. manigent/server/channels/hub_store.py +1730 -0
  117. manigent/server/channels/line.py +645 -0
  118. manigent/server/channels/line_format.py +589 -0
  119. manigent/server/channels/log_redaction.py +145 -0
  120. manigent/server/channels/store.py +3316 -0
  121. manigent/server/channels/telegram.py +422 -0
  122. manigent/server/channels/telegram_format.py +312 -0
  123. manigent/server/channels/webchat.py +261 -0
  124. manigent/server/channels/webchat_widget.js +1310 -0
  125. manigent/server/channels/whatsapp.py +707 -0
  126. manigent/server/channels/whatsapp_format.py +511 -0
  127. manigent/server/chat_search_index.py +1203 -0
  128. manigent/server/config.py +61 -0
  129. manigent/server/context.py +121 -0
  130. manigent/server/coordinator_rule.py +115 -0
  131. manigent/server/desk_todo_notify.py +300 -0
  132. manigent/server/edge_hub.py +577 -0
  133. manigent/server/email.py +354 -0
  134. manigent/server/errors.py +83 -0
  135. manigent/server/home_fs_relay.py +392 -0
  136. manigent/server/hub/adapter.py +1395 -0
  137. manigent/server/hub/agent_send_guard.py +196 -0
  138. manigent/server/hub/avatar.py +342 -0
  139. manigent/server/hub/browser.py +778 -0
  140. manigent/server/hub/client.py +859 -0
  141. manigent/server/hub/demand.py +49 -0
  142. manigent/server/hub/directory.py +385 -0
  143. manigent/server/hub/enroll.py +400 -0
  144. manigent/server/hub/http.py +134 -0
  145. manigent/server/hub/identity.py +61 -0
  146. manigent/server/hub/keys.py +588 -0
  147. manigent/server/hub/mirror.py +802 -0
  148. manigent/server/hub/operator.py +817 -0
  149. manigent/server/hub/retain.py +361 -0
  150. manigent/server/hub/room_bridge.py +741 -0
  151. manigent/server/hub/runtime.py +691 -0
  152. manigent/server/hub/trust.py +182 -0
  153. manigent/server/hub/views.py +316 -0
  154. manigent/server/idempotency.py +205 -0
  155. manigent/server/in_flight_restore.py +181 -0
  156. manigent/server/inspection.py +816 -0
  157. manigent/server/isolation_gate.py +516 -0
  158. manigent/server/isolation_notice.py +310 -0
  159. manigent/server/managed_job_events.py +242 -0
  160. manigent/server/mcp_registry.py +118 -0
  161. manigent/server/message_forward.py +78 -0
  162. manigent/server/message_scheduler.py +176 -0
  163. manigent/server/notification_assets.py +164 -0
  164. manigent/server/oauth_login/cli_state.py +120 -0
  165. manigent/server/oauth_login/config.py +90 -0
  166. manigent/server/oauth_login/errors.py +78 -0
  167. manigent/server/oauth_login/flow.py +223 -0
  168. manigent/server/oauth_login/handoff.py +63 -0
  169. manigent/server/oauth_login/providers.py +45 -0
  170. manigent/server/oauth_state.py +81 -0
  171. manigent/server/owner_isolation.py +466 -0
  172. manigent/server/participant_liveness.py +82 -0
  173. manigent/server/public_profile.py +149 -0
  174. manigent/server/public_requests/candidates.py +116 -0
  175. manigent/server/public_requests/dispatch.py +121 -0
  176. manigent/server/public_requests/ranking.py +153 -0
  177. manigent/server/recent_activity.py +250 -0
  178. manigent/server/reflection_completion_subscriber.py +151 -0
  179. manigent/server/reflection_scheduler.py +239 -0
  180. manigent/server/requester_key_migration.py +261 -0
  181. manigent/server/room_flow.py +139 -0
  182. manigent/server/routes/_batch.py +218 -0
  183. manigent/server/routes/_fd_routing.py +155 -0
  184. manigent/server/routes/_mentions.py +70 -0
  185. manigent/server/routes/agent_env.py +188 -0
  186. manigent/server/routes/agent_fs.py +450 -0
  187. manigent/server/routes/agents.py +1206 -0
  188. manigent/server/routes/announcements.py +79 -0
  189. manigent/server/routes/auth.py +387 -0
  190. manigent/server/routes/brief_tabs.py +404 -0
  191. manigent/server/routes/changelog.py +237 -0
  192. manigent/server/routes/channels.py +993 -0
  193. manigent/server/routes/chatrooms.py +871 -0
  194. manigent/server/routes/desk_labels.py +298 -0
  195. manigent/server/routes/desk_latest_messages.py +116 -0
  196. manigent/server/routes/desk_read_state.py +110 -0
  197. manigent/server/routes/desk_sign_off.py +88 -0
  198. manigent/server/routes/desk_sops.py +216 -0
  199. manigent/server/routes/desk_todos.py +692 -0
  200. manigent/server/routes/desk_tour.py +44 -0
  201. manigent/server/routes/files.py +572 -0
  202. manigent/server/routes/hosts.py +484 -0
  203. manigent/server/routes/hub.py +534 -0
  204. manigent/server/routes/hub_channels.py +234 -0
  205. manigent/server/routes/inbox.py +581 -0
  206. manigent/server/routes/install.ps1 +436 -0
  207. manigent/server/routes/install.py +324 -0
  208. manigent/server/routes/install.sh +566 -0
  209. manigent/server/routes/isolation_check.py +145 -0
  210. manigent/server/routes/knowledge_packs.py +477 -0
  211. manigent/server/routes/managed_jobs.py +196 -0
  212. manigent/server/routes/mcps.py +271 -0
  213. manigent/server/routes/messages.py +1972 -0
  214. manigent/server/routes/model_defaults.py +279 -0
  215. manigent/server/routes/oauth.py +237 -0
  216. manigent/server/routes/oauth_login.py +351 -0
  217. manigent/server/routes/owner_isolation.py +91 -0
  218. manigent/server/routes/project_plans.py +1521 -0
  219. manigent/server/routes/project_reports.py +235 -0
  220. manigent/server/routes/projects.py +2047 -0
  221. manigent/server/routes/public_agents.py +151 -0
  222. manigent/server/routes/public_requests.py +181 -0
  223. manigent/server/routes/reflection.py +93 -0
  224. manigent/server/routes/scheduled_messages.py +263 -0
  225. manigent/server/routes/search.py +94 -0
  226. manigent/server/routes/sharing.py +156 -0
  227. manigent/server/routes/skills.py +406 -0
  228. manigent/server/routes/templates.py +219 -0
  229. manigent/server/routes/turn_tokens.py +119 -0
  230. manigent/server/routes/webchat.py +406 -0
  231. manigent/server/routes/websocket.py +431 -0
  232. manigent/server/serving_only.py +200 -0
  233. manigent/server/signup_policy.py +120 -0
  234. manigent/server/skill_registry.py +164 -0
  235. manigent/server/socket_events.py +858 -0
  236. manigent/server/timeout_checker.py +446 -0
  237. manigent/server/tunnel_subscriber.py +1000 -0
  238. manigent/server/turn_tokens.py +285 -0
  239. manigent/server/ws_hub.py +556 -0
  240. manigent/server_cli.py +31 -0
  241. manigent/sync/changelog.py +885 -0
  242. manigent/sync/console_subscriber.py +222 -0
  243. manigent/sync/runloop.py +619 -0
  244. manigent/sync/runloop_manager.py +197 -0
  245. manigent/sync/subscriber.py +98 -0
  246. manigent/utils/agent_processes.py +276 -0
  247. manigent/utils/agent_storage.py +125 -0
  248. manigent/utils/email.py +563 -0
  249. manigent/utils/env_store.py +174 -0
  250. manigent/utils/extra_dirs.py +85 -0
  251. manigent/utils/file_io.py +485 -0
  252. manigent/utils/invoke_timeout.py +87 -0
  253. manigent/utils/jsonc.py +78 -0
  254. manigent/utils/knowledge_index.py +148 -0
  255. manigent/utils/nofollow_fs.py +279 -0
  256. manigent/utils/notification_center.py +67 -0
  257. manigent/utils/version.py +57 -0
  258. manigent/web/frontend/.design-sync-entry.tsx +74 -0
  259. manigent/web/frontend/.design-sync-provider.tsx +46 -0
  260. manigent/web/frontend/.eslintrc.cjs +183 -0
  261. manigent/web/frontend/.gitignore +6 -0
  262. manigent/web/frontend/app/page.tsx +39 -0
  263. manigent/web/frontend/hub-terms.html +192 -0
  264. manigent/web/frontend/index.html +15 -0
  265. manigent/web/frontend/landing.html +15 -0
  266. manigent/web/frontend/package-lock.json +8028 -0
  267. manigent/web/frontend/package.json +55 -0
  268. manigent/web/frontend/postcss.config.js +6 -0
  269. manigent/web/frontend/privacy.html +217 -0
  270. manigent/web/frontend/private-beta-announcement.html +19 -0
  271. manigent/web/frontend/public/apple-touch-icon.png +0 -0
  272. manigent/web/frontend/public/brand-hero-banner.png +0 -0
  273. manigent/web/frontend/public/brand-hero.png +0 -0
  274. manigent/web/frontend/public/favicon.png +0 -0
  275. manigent/web/frontend/public/favicon.svg +8 -0
  276. manigent/web/frontend/public/icon-transparent-sm.png +0 -0
  277. manigent/web/frontend/public/icon.png +0 -0
  278. manigent/web/frontend/public/manigent-mark.svg +7 -0
  279. manigent/web/frontend/public/vite.svg +5 -0
  280. manigent/web/frontend/public.html +15 -0
  281. manigent/web/frontend/src/App.activeAccountRedirect.test.tsx +236 -0
  282. manigent/web/frontend/src/App.hubHostDm.test.tsx +119 -0
  283. manigent/web/frontend/src/App.hubHostProject.test.tsx +142 -0
  284. manigent/web/frontend/src/App.hubRoutes.test.tsx +187 -0
  285. manigent/web/frontend/src/App.oauthAddCallback.test.tsx +263 -0
  286. manigent/web/frontend/src/App.tsx +246 -0
  287. manigent/web/frontend/src/api/attachments.ts +32 -0
  288. manigent/web/frontend/src/api/batchLoader.test.ts +212 -0
  289. manigent/web/frontend/src/api/batchLoader.ts +124 -0
  290. manigent/web/frontend/src/api/chatroomsBatch.test.tsx +392 -0
  291. manigent/web/frontend/src/api/client.ts +321 -0
  292. manigent/web/frontend/src/api/crossAccountAuth.test.ts +388 -0
  293. manigent/web/frontend/src/api/endpoints/agentEnv.ts +24 -0
  294. manigent/web/frontend/src/api/endpoints/agentFs.ts +125 -0
  295. manigent/web/frontend/src/api/endpoints/agentSearch.ts +19 -0
  296. manigent/web/frontend/src/api/endpoints/agents.ts +66 -0
  297. manigent/web/frontend/src/api/endpoints/announcements.ts +26 -0
  298. manigent/web/frontend/src/api/endpoints/auth.ts +361 -0
  299. manigent/web/frontend/src/api/endpoints/authLogin.test.ts +236 -0
  300. manigent/web/frontend/src/api/endpoints/batch.ts +64 -0
  301. manigent/web/frontend/src/api/endpoints/briefTabs.ts +81 -0
  302. manigent/web/frontend/src/api/endpoints/channels.ts +285 -0
  303. manigent/web/frontend/src/api/endpoints/computers.ts +212 -0
  304. manigent/web/frontend/src/api/endpoints/deskLabels.ts +151 -0
  305. manigent/web/frontend/src/api/endpoints/deskLatestMessages.ts +23 -0
  306. manigent/web/frontend/src/api/endpoints/deskReadState.ts +41 -0
  307. manigent/web/frontend/src/api/endpoints/deskSignOffs.ts +74 -0
  308. manigent/web/frontend/src/api/endpoints/deskSops.ts +53 -0
  309. manigent/web/frontend/src/api/endpoints/deskTodoAttachments.ts +69 -0
  310. manigent/web/frontend/src/api/endpoints/deskTodoProjects.test.ts +99 -0
  311. manigent/web/frontend/src/api/endpoints/deskTodoProjects.ts +114 -0
  312. manigent/web/frontend/src/api/endpoints/deskTodos.ts +164 -0
  313. manigent/web/frontend/src/api/endpoints/deskTour.ts +16 -0
  314. manigent/web/frontend/src/api/endpoints/dm.ts +137 -0
  315. manigent/web/frontend/src/api/endpoints/files.ts +55 -0
  316. manigent/web/frontend/src/api/endpoints/hub.contract.test.ts +94 -0
  317. manigent/web/frontend/src/api/endpoints/hub.keys.ts +260 -0
  318. manigent/web/frontend/src/api/endpoints/hub.test.ts +203 -0
  319. manigent/web/frontend/src/api/endpoints/hub.ts +654 -0
  320. manigent/web/frontend/src/api/endpoints/index.ts +15 -0
  321. manigent/web/frontend/src/api/endpoints/install.ts +74 -0
  322. manigent/web/frontend/src/api/endpoints/isolationCheck.ts +36 -0
  323. manigent/web/frontend/src/api/endpoints/knowledgePacks.ts +179 -0
  324. manigent/web/frontend/src/api/endpoints/mcps.ts +36 -0
  325. manigent/web/frontend/src/api/endpoints/messages.held.test.ts +43 -0
  326. manigent/web/frontend/src/api/endpoints/messages.ts +95 -0
  327. manigent/web/frontend/src/api/endpoints/modelConfigs.ts +232 -0
  328. manigent/web/frontend/src/api/endpoints/oauthApi.test.ts +110 -0
  329. manigent/web/frontend/src/api/endpoints/projectPlans.ts +608 -0
  330. manigent/web/frontend/src/api/endpoints/projectReports.ts +36 -0
  331. manigent/web/frontend/src/api/endpoints/projects.ts +124 -0
  332. manigent/web/frontend/src/api/endpoints/publicRequests.ts +75 -0
  333. manigent/web/frontend/src/api/endpoints/reflection.ts +32 -0
  334. manigent/web/frontend/src/api/endpoints/roomFlow.ts +14 -0
  335. manigent/web/frontend/src/api/endpoints/scheduledMessages.ts +60 -0
  336. manigent/web/frontend/src/api/endpoints/search.ts +27 -0
  337. manigent/web/frontend/src/api/endpoints/sharing.ts +42 -0
  338. manigent/web/frontend/src/api/endpoints/skills.ts +67 -0
  339. manigent/web/frontend/src/api/endpoints/templates.ts +72 -0
  340. manigent/web/frontend/src/api/loaders.ts +22 -0
  341. manigent/web/frontend/src/api/singleIssuer.test.ts +148 -0
  342. manigent/web/frontend/src/api/transientRetry.test.ts +176 -0
  343. manigent/web/frontend/src/api/transientRetry.ts +140 -0
  344. manigent/web/frontend/src/api/websocket.resync.test.ts +111 -0
  345. manigent/web/frontend/src/api/websocket.ts +335 -0
  346. manigent/web/frontend/src/components/agent/AgentNameLink.tsx +24 -0
  347. manigent/web/frontend/src/components/agent/AgentViewActions.test.tsx +41 -0
  348. manigent/web/frontend/src/components/agent/AgentViewActions.tsx +89 -0
  349. manigent/web/frontend/src/components/agent/CreateAgentPage.tsx +535 -0
  350. manigent/web/frontend/src/components/agent/JsonConfigEditor.tsx +33 -0
  351. manigent/web/frontend/src/components/agent/KnowledgePackList.tsx +103 -0
  352. manigent/web/frontend/src/components/agent/McpCatalog.tsx +261 -0
  353. manigent/web/frontend/src/components/agent/McpConfigModal.tsx +224 -0
  354. manigent/web/frontend/src/components/agent/PreConsentModal.tsx +172 -0
  355. manigent/web/frontend/src/components/agent/SkillCatalog.tsx +364 -0
  356. manigent/web/frontend/src/components/agent/SkillConfigModal.tsx +221 -0
  357. manigent/web/frontend/src/components/auth/AddAccountPage.test.tsx +181 -0
  358. manigent/web/frontend/src/components/auth/AddAccountPage.tsx +201 -0
  359. manigent/web/frontend/src/components/auth/AuthShell.tsx +462 -0
  360. manigent/web/frontend/src/components/auth/LoginPage.test.tsx +384 -0
  361. manigent/web/frontend/src/components/auth/LoginPage.tsx +129 -0
  362. manigent/web/frontend/src/components/auth/OAuthCallbackPage.test.tsx +185 -0
  363. manigent/web/frontend/src/components/auth/OAuthCallbackPage.tsx +154 -0
  364. manigent/web/frontend/src/components/auth/OAuthLinkedBanner.tsx +44 -0
  365. manigent/web/frontend/src/components/auth/ProviderButtons.tsx +94 -0
  366. manigent/web/frontend/src/components/auth/SignupPage.tsx +437 -0
  367. manigent/web/frontend/src/components/auth/StepProgress.tsx +60 -0
  368. manigent/web/frontend/src/components/auth/VerifyEmailPage.test.tsx +155 -0
  369. manigent/web/frontend/src/components/auth/VerifyEmailPage.tsx +211 -0
  370. manigent/web/frontend/src/components/auth/index.ts +3 -0
  371. manigent/web/frontend/src/components/auth/oauthErrors.test.ts +107 -0
  372. manigent/web/frontend/src/components/auth/oauthErrors.ts +82 -0
  373. manigent/web/frontend/src/components/auth/useUsernameCheck.test.tsx +68 -0
  374. manigent/web/frontend/src/components/auth/useUsernameCheck.ts +86 -0
  375. manigent/web/frontend/src/components/beta/BetaPage.tsx +21 -0
  376. manigent/web/frontend/src/components/beta/DayAtMyDesk.tsx +120 -0
  377. manigent/web/frontend/src/components/beta/Footer.tsx +82 -0
  378. manigent/web/frontend/src/components/beta/Hero.tsx +224 -0
  379. manigent/web/frontend/src/components/beta/Nav.tsx +51 -0
  380. manigent/web/frontend/src/components/beta/Pillars.tsx +172 -0
  381. manigent/web/frontend/src/components/beta/TwoModels.tsx +146 -0
  382. manigent/web/frontend/src/components/beta/constants.ts +3 -0
  383. manigent/web/frontend/src/components/briefing/BriefingFrame.test.tsx +208 -0
  384. manigent/web/frontend/src/components/briefing/BriefingFrame.tsx +151 -0
  385. manigent/web/frontend/src/components/briefing/BriefingPage.test.tsx +176 -0
  386. manigent/web/frontend/src/components/briefing/BriefingPage.tsx +157 -0
  387. manigent/web/frontend/src/components/briefing/BriefingPage.wsDelete.test.tsx +177 -0
  388. manigent/web/frontend/src/components/briefing/briefing.css +147 -0
  389. manigent/web/frontend/src/components/briefing/heightBootstrap.test.ts +121 -0
  390. manigent/web/frontend/src/components/briefing/heightBootstrap.ts +224 -0
  391. manigent/web/frontend/src/components/briefing/parseBriefMessage.test.ts +90 -0
  392. manigent/web/frontend/src/components/briefing/sampleBriefing.ts +148 -0
  393. manigent/web/frontend/src/components/channels/ChannelBadge.tsx +35 -0
  394. manigent/web/frontend/src/components/chatroom/ChannelThreadControls.tsx +127 -0
  395. manigent/web/frontend/src/components/chatroom/ChannelThreadNotice.test.tsx +207 -0
  396. manigent/web/frontend/src/components/chatroom/ChannelThreadNotice.tsx +80 -0
  397. manigent/web/frontend/src/components/chatroom/ChatroomView.channel.test.tsx +550 -0
  398. manigent/web/frontend/src/components/chatroom/ChatroomView.flow.test.tsx +186 -0
  399. manigent/web/frontend/src/components/chatroom/ChatroomView.isolation.test.tsx +70 -0
  400. manigent/web/frontend/src/components/chatroom/ChatroomView.tsx +450 -0
  401. manigent/web/frontend/src/components/chatroom/DelegationRow.test.tsx +170 -0
  402. manigent/web/frontend/src/components/chatroom/DelegationRow.tsx +217 -0
  403. manigent/web/frontend/src/components/chatroom/DeliveryStatusLine.test.tsx +33 -0
  404. manigent/web/frontend/src/components/chatroom/DeliveryStatusLine.tsx +46 -0
  405. manigent/web/frontend/src/components/chatroom/ForwardModal.test.tsx +109 -0
  406. manigent/web/frontend/src/components/chatroom/ForwardModal.tsx +304 -0
  407. manigent/web/frontend/src/components/chatroom/HeldNotice.tsx +77 -0
  408. manigent/web/frontend/src/components/chatroom/MentionDropdown.tsx +97 -0
  409. manigent/web/frontend/src/components/chatroom/MessageInput.tsx +408 -0
  410. manigent/web/frontend/src/components/chatroom/MessageItem.tsx +425 -0
  411. manigent/web/frontend/src/components/chatroom/MessageList.outside.test.tsx +98 -0
  412. manigent/web/frontend/src/components/chatroom/MessageList.test.tsx +245 -0
  413. manigent/web/frontend/src/components/chatroom/MessageList.tsx +406 -0
  414. manigent/web/frontend/src/components/chatroom/OfflineRemedyNote.test.tsx +211 -0
  415. manigent/web/frontend/src/components/chatroom/OfflineRemedyNote.tsx +182 -0
  416. manigent/web/frontend/src/components/chatroom/OutsideBadge.tsx +18 -0
  417. manigent/web/frontend/src/components/chatroom/RoomFlowControl.tsx +49 -0
  418. manigent/web/frontend/src/components/chatroom/RoomFlowStrip.test.tsx +47 -0
  419. manigent/web/frontend/src/components/chatroom/RoomFlowStrip.tsx +25 -0
  420. manigent/web/frontend/src/components/chatroom/SaveToPackModal.tsx +350 -0
  421. manigent/web/frontend/src/components/chatroom/StagedAttachments.test.tsx +56 -0
  422. manigent/web/frontend/src/components/chatroom/StagedAttachments.tsx +160 -0
  423. manigent/web/frontend/src/components/chatroom/SystemNote.tsx +18 -0
  424. manigent/web/frontend/src/components/chatroom/contactSender.test.tsx +130 -0
  425. manigent/web/frontend/src/components/chatroom/index.ts +3 -0
  426. manigent/web/frontend/src/components/chatroom/recipientStatus.test.ts +344 -0
  427. manigent/web/frontend/src/components/chatroom/recipientStatus.ts +197 -0
  428. manigent/web/frontend/src/components/common/AnnouncementBanners.test.tsx +221 -0
  429. manigent/web/frontend/src/components/common/AnnouncementBanners.tsx +180 -0
  430. manigent/web/frontend/src/components/common/Avatar.test.tsx +50 -0
  431. manigent/web/frontend/src/components/common/Avatar.tsx +102 -0
  432. manigent/web/frontend/src/components/common/Badge.tsx +30 -0
  433. manigent/web/frontend/src/components/common/BrandName.tsx +16 -0
  434. manigent/web/frontend/src/components/common/ClearHistoryButton.tsx +74 -0
  435. manigent/web/frontend/src/components/common/CollapsibleHeader.tsx +64 -0
  436. manigent/web/frontend/src/components/common/ConnectionStatus.test.tsx +56 -0
  437. manigent/web/frontend/src/components/common/ConnectionStatus.tsx +28 -0
  438. manigent/web/frontend/src/components/common/DropOverlay.tsx +27 -0
  439. manigent/web/frontend/src/components/common/InlineEditableTitle.test.tsx +369 -0
  440. manigent/web/frontend/src/components/common/InlineEditableTitle.tsx +287 -0
  441. manigent/web/frontend/src/components/common/IsolationUnavailablePanel.tsx +19 -0
  442. manigent/web/frontend/src/components/common/Loading.tsx +22 -0
  443. manigent/web/frontend/src/components/common/ModelSelector.tsx +248 -0
  444. manigent/web/frontend/src/components/common/Overlay.tsx +15 -0
  445. manigent/web/frontend/src/components/common/ResizeHandle.tsx +52 -0
  446. manigent/web/frontend/src/components/common/TagAutocompleteInput.test.tsx +92 -0
  447. manigent/web/frontend/src/components/common/TagAutocompleteInput.tsx +246 -0
  448. manigent/web/frontend/src/components/common/TagPillInput.tsx +181 -0
  449. manigent/web/frontend/src/components/common/TeamAndAgentPicker.tsx +83 -0
  450. manigent/web/frontend/src/components/common/Timestamp.tsx +61 -0
  451. manigent/web/frontend/src/components/common/VerifiedBadge.tsx +20 -0
  452. manigent/web/frontend/src/components/common/index.ts +8 -0
  453. manigent/web/frontend/src/components/composer/PhPopover.tsx +239 -0
  454. manigent/web/frontend/src/components/composer/PromptEditor.test.tsx +472 -0
  455. manigent/web/frontend/src/components/composer/PromptEditor.tsx +540 -0
  456. manigent/web/frontend/src/components/composer/caret.ts +159 -0
  457. manigent/web/frontend/src/components/composer/placeholders.test.ts +352 -0
  458. manigent/web/frontend/src/components/composer/placeholders.ts +354 -0
  459. manigent/web/frontend/src/components/composer/promptEditor.css +161 -0
  460. manigent/web/frontend/src/components/computer/ComputerPage.test.tsx +452 -0
  461. manigent/web/frontend/src/components/computer/ComputerPage.tsx +811 -0
  462. manigent/web/frontend/src/components/computer/ConnectComputerModal.tsx +135 -0
  463. manigent/web/frontend/src/components/computer/CopyCommand.tsx +77 -0
  464. manigent/web/frontend/src/components/computer/DisconnectComputerModal.tsx +121 -0
  465. manigent/web/frontend/src/components/computer/SoftwareCard.tsx +145 -0
  466. manigent/web/frontend/src/components/computer/TerminalCard.test.tsx +114 -0
  467. manigent/web/frontend/src/components/computer/TerminalCard.tsx +131 -0
  468. manigent/web/frontend/src/components/computer/TerminalPanel.tsx +226 -0
  469. manigent/web/frontend/src/components/computer/TrustPanel.test.tsx +58 -0
  470. manigent/web/frontend/src/components/computer/TrustPanel.tsx +143 -0
  471. manigent/web/frontend/src/components/computer/computerCopy.test.ts +322 -0
  472. manigent/web/frontend/src/components/computer/computerCopy.ts +413 -0
  473. manigent/web/frontend/src/components/computer/sections.ts +21 -0
  474. manigent/web/frontend/src/components/computer/terminalProtocol.test.ts +135 -0
  475. manigent/web/frontend/src/components/computer/terminalProtocol.ts +170 -0
  476. manigent/web/frontend/src/components/context/ContextGuide.test.tsx +26 -0
  477. manigent/web/frontend/src/components/context/ContextGuide.tsx +144 -0
  478. manigent/web/frontend/src/components/context/ContextLanding.tsx +46 -0
  479. manigent/web/frontend/src/components/desk/CardBand.a11y.test.tsx +138 -0
  480. manigent/web/frontend/src/components/desk/CardBand.tsx +132 -0
  481. manigent/web/frontend/src/components/desk/DashTile.tsx +81 -0
  482. manigent/web/frontend/src/components/desk/DeskAgentPicker.retained.test.tsx +79 -0
  483. manigent/web/frontend/src/components/desk/DeskAgentPicker.tsx +151 -0
  484. manigent/web/frontend/src/components/desk/DeskComposer.detachTodo.test.tsx +233 -0
  485. manigent/web/frontend/src/components/desk/DeskComposer.dispatch.test.tsx +208 -0
  486. manigent/web/frontend/src/components/desk/DeskComposer.loadTodoDraft.test.tsx +242 -0
  487. manigent/web/frontend/src/components/desk/DeskComposer.restage.test.tsx +258 -0
  488. manigent/web/frontend/src/components/desk/DeskComposer.saveTodo.test.tsx +265 -0
  489. manigent/web/frontend/src/components/desk/DeskComposer.tsx +828 -0
  490. manigent/web/frontend/src/components/desk/DeskPage.crossHighlight.test.tsx +427 -0
  491. manigent/web/frontend/src/components/desk/DeskPage.link.test.tsx +271 -0
  492. manigent/web/frontend/src/components/desk/DeskPage.tsx +1066 -0
  493. manigent/web/frontend/src/components/desk/DeskPage.welcomeTour.test.tsx +262 -0
  494. manigent/web/frontend/src/components/desk/DeskSideRail.tsx +100 -0
  495. manigent/web/frontend/src/components/desk/DeskWelcomeTour.tsx +513 -0
  496. manigent/web/frontend/src/components/desk/DeskZeroState.tsx +45 -0
  497. manigent/web/frontend/src/components/desk/LabelChip.tsx +147 -0
  498. manigent/web/frontend/src/components/desk/LabelFilter.tsx +474 -0
  499. manigent/web/frontend/src/components/desk/LabelInput.test.tsx +439 -0
  500. manigent/web/frontend/src/components/desk/LabelInput.tsx +477 -0
  501. manigent/web/frontend/src/components/desk/LabelManageSheet.test.tsx +384 -0
  502. manigent/web/frontend/src/components/desk/LabelManageSheet.tsx +498 -0
  503. manigent/web/frontend/src/components/desk/PhaseRail.test.tsx +170 -0
  504. manigent/web/frontend/src/components/desk/PhaseRail.tsx +120 -0
  505. manigent/web/frontend/src/components/desk/PlanBand.test.tsx +655 -0
  506. manigent/web/frontend/src/components/desk/PlanBand.tsx +216 -0
  507. manigent/web/frontend/src/components/desk/SaveTodoModal.tsx +126 -0
  508. manigent/web/frontend/src/components/desk/SopEditor.tsx +280 -0
  509. manigent/web/frontend/src/components/desk/SopItem.tsx +171 -0
  510. manigent/web/frontend/src/components/desk/SopRail.test.tsx +517 -0
  511. manigent/web/frontend/src/components/desk/SopRail.tsx +250 -0
  512. manigent/web/frontend/src/components/desk/TicketChip.tsx +51 -0
  513. manigent/web/frontend/src/components/desk/TodoItem.attachments.test.tsx +96 -0
  514. manigent/web/frontend/src/components/desk/TodoItem.menu.test.tsx +142 -0
  515. manigent/web/frontend/src/components/desk/TodoItem.tsx +573 -0
  516. manigent/web/frontend/src/components/desk/TodoRail.longpress.test.tsx +396 -0
  517. manigent/web/frontend/src/components/desk/TodoRail.test.tsx +1155 -0
  518. manigent/web/frontend/src/components/desk/TodoRail.tsx +918 -0
  519. manigent/web/frontend/src/components/desk/TodoRowMenu.tsx +147 -0
  520. manigent/web/frontend/src/components/desk/UpdateCard.bands.test.tsx +308 -0
  521. manigent/web/frontend/src/components/desk/UpdateCard.sendBack.test.tsx +444 -0
  522. manigent/web/frontend/src/components/desk/UpdateCard.tsx +943 -0
  523. manigent/web/frontend/src/components/desk/UpdateCard.untrusted.test.tsx +161 -0
  524. manigent/web/frontend/src/components/desk/desk.css +1921 -0
  525. manigent/web/frontend/src/components/desk/desk.css.test.ts +582 -0
  526. manigent/web/frontend/src/components/desk/deskBands.test.ts +389 -0
  527. manigent/web/frontend/src/components/desk/deskBands.ts +226 -0
  528. manigent/web/frontend/src/components/desk/m16Surfaces.test.ts +134 -0
  529. manigent/web/frontend/src/components/desk/sopSyntax.ts +73 -0
  530. manigent/web/frontend/src/components/discovery/AgentDirectory.tsx +71 -0
  531. manigent/web/frontend/src/components/discovery/AgentFilterBar.tsx +46 -0
  532. manigent/web/frontend/src/components/discovery/AgentSearchBar.tsx +56 -0
  533. manigent/web/frontend/src/components/dm/AgentSummaryCard.tsx +154 -0
  534. manigent/web/frontend/src/components/dm/ComposeFields.tsx +145 -0
  535. manigent/web/frontend/src/components/dm/DMChatView.flow.test.tsx +374 -0
  536. manigent/web/frontend/src/components/dm/DMChatView.tsx +841 -0
  537. manigent/web/frontend/src/components/dm/DMZeroState.tsx +204 -0
  538. manigent/web/frontend/src/components/dm/DmComposeModal.tsx +301 -0
  539. manigent/web/frontend/src/components/dm/PersonalizeModal.tsx +366 -0
  540. manigent/web/frontend/src/components/dm/index.ts +1 -0
  541. manigent/web/frontend/src/components/files/FileItem.tsx +138 -0
  542. manigent/web/frontend/src/components/files/FileList.outside.test.tsx +29 -0
  543. manigent/web/frontend/src/components/files/FileList.tsx +48 -0
  544. manigent/web/frontend/src/components/files/FilePreview.test.tsx +75 -0
  545. manigent/web/frontend/src/components/files/FilePreview.tsx +325 -0
  546. manigent/web/frontend/src/components/files/FileViewerModal.test.tsx +157 -0
  547. manigent/web/frontend/src/components/files/FileViewerModal.tsx +261 -0
  548. manigent/web/frontend/src/components/files/HighlightedCode.tsx +39 -0
  549. manigent/web/frontend/src/components/files/SaveToAgentPanel.test.tsx +156 -0
  550. manigent/web/frontend/src/components/files/SaveToAgentPanel.tsx +545 -0
  551. manigent/web/frontend/src/components/files/index.ts +3 -0
  552. manigent/web/frontend/src/components/hero/AgentAvatar.tsx +66 -0
  553. manigent/web/frontend/src/components/hero/Button.tsx +66 -0
  554. manigent/web/frontend/src/components/hero/Hero.tsx +133 -0
  555. manigent/web/frontend/src/components/homefs/HomeFolderBrowser.test.tsx +393 -0
  556. manigent/web/frontend/src/components/homefs/HomeFolderBrowser.tsx +792 -0
  557. manigent/web/frontend/src/components/hub/BridgeDeliveryLine.test.tsx +40 -0
  558. manigent/web/frontend/src/components/hub/BridgeDeliveryLine.tsx +36 -0
  559. manigent/web/frontend/src/components/hub/HubAvatar.test.tsx +93 -0
  560. manigent/web/frontend/src/components/hub/HubAvatar.tsx +33 -0
  561. manigent/web/frontend/src/components/hub/HubChatView.test.tsx +695 -0
  562. manigent/web/frontend/src/components/hub/HubChatView.tsx +504 -0
  563. manigent/web/frontend/src/components/hub/HubContactLabel.test.tsx +92 -0
  564. manigent/web/frontend/src/components/hub/HubContactLabel.tsx +47 -0
  565. manigent/web/frontend/src/components/hub/HubDeliveryLine.tsx +49 -0
  566. manigent/web/frontend/src/components/hub/HubDisclosure.tsx +49 -0
  567. manigent/web/frontend/src/components/hub/HubEnrollmentDialog.test.tsx +144 -0
  568. manigent/web/frontend/src/components/hub/HubEnrollmentDialog.tsx +244 -0
  569. manigent/web/frontend/src/components/hub/HubMessageBody.test.tsx +26 -0
  570. manigent/web/frontend/src/components/hub/HubMessageBody.tsx +14 -0
  571. manigent/web/frontend/src/components/hub/HubOfflineConfirm.tsx +45 -0
  572. manigent/web/frontend/src/components/hub/HubThreadBanner.tsx +33 -0
  573. manigent/web/frontend/src/components/hub/HubThreadGuard.tsx +59 -0
  574. manigent/web/frontend/src/components/hub/RemoteAgentCard.test.tsx +50 -0
  575. manigent/web/frontend/src/components/hub/RemoteAgentCard.tsx +174 -0
  576. manigent/web/frontend/src/components/hub/RemoteAgentSection.test.tsx +446 -0
  577. manigent/web/frontend/src/components/hub/RemoteAgentSection.tsx +219 -0
  578. manigent/web/frontend/src/components/hub/ReportDialog.test.tsx +96 -0
  579. manigent/web/frontend/src/components/hub/ReportDialog.tsx +162 -0
  580. manigent/web/frontend/src/components/hub/RetainButton.test.tsx +103 -0
  581. manigent/web/frontend/src/components/hub/RetainButton.tsx +114 -0
  582. manigent/web/frontend/src/components/hub/RetainDialog.test.tsx +68 -0
  583. manigent/web/frontend/src/components/hub/RetainDialog.tsx +115 -0
  584. manigent/web/frontend/src/components/hub/RetainedList.test.tsx +94 -0
  585. manigent/web/frontend/src/components/hub/RetainedList.tsx +32 -0
  586. manigent/web/frontend/src/components/hub/RetainedRow.tsx +129 -0
  587. manigent/web/frontend/src/components/hub/StandInChip.test.tsx +37 -0
  588. manigent/web/frontend/src/components/hub/StandInChip.tsx +43 -0
  589. manigent/web/frontend/src/components/hub/UnusualNameChip.test.tsx +81 -0
  590. manigent/web/frontend/src/components/hub/UnusualNameChip.tsx +22 -0
  591. manigent/web/frontend/src/components/hub/hubLint.test.ts +265 -0
  592. manigent/web/frontend/src/components/landing/ComparisonSection.tsx +92 -0
  593. manigent/web/frontend/src/components/landing/ConsoleSection.tsx +402 -0
  594. manigent/web/frontend/src/components/landing/CrewSection.tsx +224 -0
  595. manigent/web/frontend/src/components/landing/FAQ.tsx +68 -0
  596. manigent/web/frontend/src/components/landing/Footer.tsx +58 -0
  597. manigent/web/frontend/src/components/landing/HowItWorks.tsx +117 -0
  598. manigent/web/frontend/src/components/landing/KnowledgeSection.tsx +91 -0
  599. manigent/web/frontend/src/components/landing/LandingPage.test.tsx +134 -0
  600. manigent/web/frontend/src/components/landing/LearningSection.tsx +486 -0
  601. manigent/web/frontend/src/components/landing/Nav.tsx +68 -0
  602. manigent/web/frontend/src/components/landing/Pricing.tsx +263 -0
  603. manigent/web/frontend/src/components/landing/Section.tsx +98 -0
  604. manigent/web/frontend/src/components/landing/StaffingSection.tsx +122 -0
  605. manigent/web/frontend/src/components/landing/WhyNowSection.tsx +92 -0
  606. manigent/web/frontend/src/components/landing/WhyTeamSection.tsx +73 -0
  607. manigent/web/frontend/src/components/landing/earlyAccess.ts +23 -0
  608. manigent/web/frontend/src/components/layout/AppLayout.tsx +77 -0
  609. manigent/web/frontend/src/components/layout/Sidebar.tsx +172 -0
  610. manigent/web/frontend/src/components/layout/index.ts +2 -0
  611. manigent/web/frontend/src/components/project/ActivityFeed.tsx +247 -0
  612. manigent/web/frontend/src/components/project/CreateProjectPage.tsx +543 -0
  613. manigent/web/frontend/src/components/project/DeleteProjectButton.tsx +39 -0
  614. manigent/web/frontend/src/components/project/ProjectPlanTab.phase.test.tsx +107 -0
  615. manigent/web/frontend/src/components/project/ProjectPlanTab.test.tsx +994 -0
  616. manigent/web/frontend/src/components/project/ProjectPlanTab.tsx +166 -0
  617. manigent/web/frontend/src/components/project/ProjectReportTab.tsx +139 -0
  618. manigent/web/frontend/src/components/project/ProjectView.tsx +390 -0
  619. manigent/web/frontend/src/components/project/index.ts +2 -0
  620. manigent/web/frontend/src/components/project/plan/PlanAcceptance.test.tsx +460 -0
  621. manigent/web/frontend/src/components/project/plan/PlanAcceptance.tsx +111 -0
  622. manigent/web/frontend/src/components/project/plan/PlanBlockEdit.tsx +111 -0
  623. manigent/web/frontend/src/components/project/plan/PlanBlockEditing.test.tsx +1132 -0
  624. manigent/web/frontend/src/components/project/plan/PlanBody.test.tsx +495 -0
  625. manigent/web/frontend/src/components/project/plan/PlanBody.tsx +992 -0
  626. manigent/web/frontend/src/components/project/plan/PlanCollide.tsx +254 -0
  627. manigent/web/frontend/src/components/project/plan/PlanCommentList.test.tsx +420 -0
  628. manigent/web/frontend/src/components/project/plan/PlanCommentList.tsx +391 -0
  629. manigent/web/frontend/src/components/project/plan/PlanDecisionNudge.test.tsx +226 -0
  630. manigent/web/frontend/src/components/project/plan/PlanDecisionNudge.tsx +128 -0
  631. manigent/web/frontend/src/components/project/plan/PlanDestructiveAccept.test.tsx +209 -0
  632. manigent/web/frontend/src/components/project/plan/PlanDiff.test.tsx +126 -0
  633. manigent/web/frontend/src/components/project/plan/PlanDiff.tsx +131 -0
  634. manigent/web/frontend/src/components/project/plan/PlanDiffSeries.test.tsx +500 -0
  635. manigent/web/frontend/src/components/project/plan/PlanDiffSeries.tsx +125 -0
  636. manigent/web/frontend/src/components/project/plan/PlanEditor.mounts.test.tsx +504 -0
  637. manigent/web/frontend/src/components/project/plan/PlanEditor.tsx +2159 -0
  638. manigent/web/frontend/src/components/project/plan/PlanHistory.tsx +69 -0
  639. manigent/web/frontend/src/components/project/plan/PlanInlineNotes.test.tsx +2398 -0
  640. manigent/web/frontend/src/components/project/plan/PlanNoteBase.test.tsx +215 -0
  641. manigent/web/frontend/src/components/project/plan/PlanNoteRow.test.tsx +562 -0
  642. manigent/web/frontend/src/components/project/plan/PlanNoteRow.tsx +908 -0
  643. manigent/web/frontend/src/components/project/plan/PlanQuotePop.tsx +254 -0
  644. manigent/web/frontend/src/components/project/plan/PlanStaleReconcile.test.tsx +244 -0
  645. manigent/web/frontend/src/components/project/plan/PlanSubmitWindow.test.tsx +281 -0
  646. manigent/web/frontend/src/components/project/plan/PlanTray.tsx +184 -0
  647. manigent/web/frontend/src/components/project/plan/PlanTrayMirror.test.tsx +242 -0
  648. manigent/web/frontend/src/components/project/plan/plan.css +1151 -0
  649. manigent/web/frontend/src/components/project/plan/planAnchor.test.tsx +459 -0
  650. manigent/web/frontend/src/components/project/plan/planAnchor.tsx +277 -0
  651. manigent/web/frontend/src/components/project/plan/planBlocks.test.tsx +470 -0
  652. manigent/web/frontend/src/components/project/plan/planBlocks.ts +242 -0
  653. manigent/web/frontend/src/components/project/plan/planContrast.test.ts +229 -0
  654. manigent/web/frontend/src/components/project/plan/planDiffs.test.ts +455 -0
  655. manigent/web/frontend/src/components/project/plan/planDiffs.ts +358 -0
  656. manigent/web/frontend/src/components/project/plan/planTestServer.ts +741 -0
  657. manigent/web/frontend/src/components/project/plan/planTransport.test.ts +160 -0
  658. manigent/web/frontend/src/components/project/plan/planTrayLogic.test.ts +1201 -0
  659. manigent/web/frontend/src/components/project/plan/planTrayLogic.ts +1164 -0
  660. manigent/web/frontend/src/components/publicAgents/PublicAgentsAccount.tsx +162 -0
  661. manigent/web/frontend/src/components/publicAgents/api.ts +55 -0
  662. manigent/web/frontend/src/components/publicAgents/parts.tsx +54 -0
  663. manigent/web/frontend/src/components/publicAgents/publicAgents.css +53 -0
  664. manigent/web/frontend/src/components/publicAgents/publicAgents.test.tsx +86 -0
  665. manigent/web/frontend/src/components/publicAgents/widget.test.ts +148 -0
  666. manigent/web/frontend/src/components/publicAgents/widget.ts +40 -0
  667. manigent/web/frontend/src/components/publicRequests/AgentDrawer.tsx +101 -0
  668. manigent/web/frontend/src/components/publicRequests/AgentPanel.tsx +173 -0
  669. manigent/web/frontend/src/components/publicRequests/AgentRankList.tsx +61 -0
  670. manigent/web/frontend/src/components/publicRequests/MatchRetainButton.tsx +67 -0
  671. manigent/web/frontend/src/components/publicRequests/ProgressBoard.tsx +74 -0
  672. manigent/web/frontend/src/components/publicRequests/PublicRequestsPage.tsx +57 -0
  673. manigent/web/frontend/src/components/publicRequests/RequestHome.tsx +99 -0
  674. manigent/web/frontend/src/components/publicRequests/RequestRoom.tsx +126 -0
  675. manigent/web/frontend/src/components/publicRequests/agentView.ts +65 -0
  676. manigent/web/frontend/src/components/publicRequests/publicRequests.css +209 -0
  677. manigent/web/frontend/src/components/publicRequests/publicRequests.test.tsx +172 -0
  678. manigent/web/frontend/src/components/publicRequests/requestCopy.ts +121 -0
  679. manigent/web/frontend/src/components/sample-request/SampleIdeaList.test.tsx +190 -0
  680. manigent/web/frontend/src/components/sample-request/SampleIdeaList.tsx +180 -0
  681. manigent/web/frontend/src/components/sample-request/SampleRequestBody.tsx +276 -0
  682. manigent/web/frontend/src/components/sample-request/assistantStarterSamples.ts +62 -0
  683. manigent/web/frontend/src/components/sample-request/sampleIdeaList.css +106 -0
  684. manigent/web/frontend/src/components/scheduled/CronPicker.tsx +80 -0
  685. manigent/web/frontend/src/components/scheduled/ScheduleMessageModal.tsx +341 -0
  686. manigent/web/frontend/src/components/scheduled/ScheduledMessagesList.tsx +157 -0
  687. manigent/web/frontend/src/components/settings/AgentSettingsPage.tsx +366 -0
  688. manigent/web/frontend/src/components/settings/ConnectedAccounts.tsx +277 -0
  689. manigent/web/frontend/src/components/settings/DefaultModelSection.test.tsx +96 -0
  690. manigent/web/frontend/src/components/settings/DefaultModelSection.tsx +212 -0
  691. manigent/web/frontend/src/components/settings/DisconnectConfirmModal.tsx +93 -0
  692. manigent/web/frontend/src/components/settings/FrontDeskSettingsPage.isolation.test.tsx +66 -0
  693. manigent/web/frontend/src/components/settings/FrontDeskSettingsPage.tsx +134 -0
  694. manigent/web/frontend/src/components/settings/InvitableAgentsPicker.tsx +182 -0
  695. manigent/web/frontend/src/components/settings/KnowledgePacksSection.tsx +867 -0
  696. manigent/web/frontend/src/components/settings/ModelConfigDialog.catalogDriven.test.tsx +79 -0
  697. manigent/web/frontend/src/components/settings/ModelConfigDialog.tsx +343 -0
  698. manigent/web/frontend/src/components/settings/ModelConfigSection.smartRouting.test.tsx +109 -0
  699. manigent/web/frontend/src/components/settings/ModelConfigSection.tsx +599 -0
  700. manigent/web/frontend/src/components/settings/ReflectionScheduleSection.tsx +170 -0
  701. manigent/web/frontend/src/components/settings/UserSettingsPage.tsx +168 -0
  702. manigent/web/frontend/src/components/settings/agent/AgentStatusStrip.tsx +71 -0
  703. manigent/web/frontend/src/components/settings/agent/BlockedDeploymentsCard.test.tsx +81 -0
  704. manigent/web/frontend/src/components/settings/agent/BlockedDeploymentsCard.tsx +62 -0
  705. manigent/web/frontend/src/components/settings/agent/ChannelIsolationLine.tsx +21 -0
  706. manigent/web/frontend/src/components/settings/agent/ChannelsSection.test.tsx +879 -0
  707. manigent/web/frontend/src/components/settings/agent/ChannelsSection.tsx +1124 -0
  708. manigent/web/frontend/src/components/settings/agent/EnvVarsCard.test.ts +15 -0
  709. manigent/web/frontend/src/components/settings/agent/EnvVarsCard.tsx +301 -0
  710. manigent/web/frontend/src/components/settings/agent/ExtraDirsList.tsx +85 -0
  711. manigent/web/frontend/src/components/settings/agent/GeneralSection.tsx +88 -0
  712. manigent/web/frontend/src/components/settings/agent/HostDisclaimerSlot.test.tsx +32 -0
  713. manigent/web/frontend/src/components/settings/agent/HostDisclaimerSlot.tsx +30 -0
  714. manigent/web/frontend/src/components/settings/agent/HubChannelCard.test.tsx +449 -0
  715. manigent/web/frontend/src/components/settings/agent/HubChannelCard.tsx +312 -0
  716. manigent/web/frontend/src/components/settings/agent/HubInvitableAgentsCard.test.tsx +83 -0
  717. manigent/web/frontend/src/components/settings/agent/HubInvitableAgentsCard.tsx +64 -0
  718. manigent/web/frontend/src/components/settings/agent/HubSendUsageCard.test.tsx +69 -0
  719. manigent/web/frontend/src/components/settings/agent/HubSendUsageCard.tsx +42 -0
  720. manigent/web/frontend/src/components/settings/agent/HubTurnOffDialog.tsx +88 -0
  721. manigent/web/frontend/src/components/settings/agent/IsolationRecheckButton.test.tsx +26 -0
  722. manigent/web/frontend/src/components/settings/agent/IsolationRecheckButton.tsx +68 -0
  723. manigent/web/frontend/src/components/settings/agent/KnowledgeSection.tsx +68 -0
  724. manigent/web/frontend/src/components/settings/agent/LineChannelCard.test.tsx +428 -0
  725. manigent/web/frontend/src/components/settings/agent/LineChannelCard.tsx +343 -0
  726. manigent/web/frontend/src/components/settings/agent/RuntimeSection.test.tsx +164 -0
  727. manigent/web/frontend/src/components/settings/agent/RuntimeSection.tsx +118 -0
  728. manigent/web/frontend/src/components/settings/agent/StorageSection.tsx +78 -0
  729. manigent/web/frontend/src/components/settings/agent/WebChatChannelCard.test.tsx +253 -0
  730. manigent/web/frontend/src/components/settings/agent/WebChatChannelCard.tsx +401 -0
  731. manigent/web/frontend/src/components/settings/agent/WhatsAppChannelCard.test.tsx +462 -0
  732. manigent/web/frontend/src/components/settings/agent/WhatsAppChannelCard.tsx +289 -0
  733. manigent/web/frontend/src/components/settings/agent/agentSettings.test.tsx +109 -0
  734. manigent/web/frontend/src/components/settings/agent/channelSetupGuides.test.tsx +162 -0
  735. manigent/web/frontend/src/components/settings/agent/channelSetupGuides.tsx +119 -0
  736. manigent/web/frontend/src/components/settings/agent/channelsShared.ts +83 -0
  737. manigent/web/frontend/src/components/settings/agent/fields.tsx +67 -0
  738. manigent/web/frontend/src/components/settings/agent/sections.ts +36 -0
  739. manigent/web/frontend/src/components/settings/agent/useAgentSettingsDraft.ts +129 -0
  740. manigent/web/frontend/src/components/settings/agent/useInstallSelection.ts +38 -0
  741. manigent/web/frontend/src/components/settings/primitives.tsx +98 -0
  742. manigent/web/frontend/src/components/shared/ExpandToggle.tsx +45 -0
  743. manigent/web/frontend/src/components/shared/MarkdownBody.tsx +218 -0
  744. manigent/web/frontend/src/components/shared/MarkdownBody.untrusted.test.tsx +97 -0
  745. manigent/web/frontend/src/components/shared/RenderBody.tsx +90 -0
  746. manigent/web/frontend/src/components/shared/rehypeHideComments.test.tsx +115 -0
  747. manigent/web/frontend/src/components/shared/rehypeHideComments.ts +124 -0
  748. manigent/web/frontend/src/components/sharing/AcceptSharePage.tsx +141 -0
  749. manigent/web/frontend/src/components/sharing/ShareLandingPage.tsx +89 -0
  750. manigent/web/frontend/src/components/sidebar/AccountChip.test.tsx +337 -0
  751. manigent/web/frontend/src/components/sidebar/AccountChip.tsx +407 -0
  752. manigent/web/frontend/src/components/sidebar/ActiveWorkDot.tsx +9 -0
  753. manigent/web/frontend/src/components/sidebar/AgentProfile.tsx +44 -0
  754. manigent/web/frontend/src/components/sidebar/AgentRow.tsx +71 -0
  755. manigent/web/frontend/src/components/sidebar/AgentsPanel.retained.test.tsx +105 -0
  756. manigent/web/frontend/src/components/sidebar/AgentsPanel.tsx +231 -0
  757. manigent/web/frontend/src/components/sidebar/AssistantHero.tsx +48 -0
  758. manigent/web/frontend/src/components/sidebar/ChatroomItem.tsx +48 -0
  759. manigent/web/frontend/src/components/sidebar/CommandPalette.channelThreads.test.tsx +76 -0
  760. manigent/web/frontend/src/components/sidebar/CommandPalette.reopen.test.tsx +206 -0
  761. manigent/web/frontend/src/components/sidebar/CommandPalette.test.tsx +725 -0
  762. manigent/web/frontend/src/components/sidebar/CommandPalette.tsx +774 -0
  763. manigent/web/frontend/src/components/sidebar/ComputersPanel.tsx +144 -0
  764. manigent/web/frontend/src/components/sidebar/DmIcon.tsx +46 -0
  765. manigent/web/frontend/src/components/sidebar/DmsPanel.tsx +226 -0
  766. manigent/web/frontend/src/components/sidebar/FrontDeskPanel.channels.test.tsx +582 -0
  767. manigent/web/frontend/src/components/sidebar/FrontDeskPanel.test.tsx +151 -0
  768. manigent/web/frontend/src/components/sidebar/FrontDeskPanel.tsx +454 -0
  769. manigent/web/frontend/src/components/sidebar/HotkeysHelp.tsx +101 -0
  770. manigent/web/frontend/src/components/sidebar/MessageHitRow.test.tsx +84 -0
  771. manigent/web/frontend/src/components/sidebar/MessageHitRow.tsx +57 -0
  772. manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.crossHighlight.test.tsx +247 -0
  773. manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.delete.test.tsx +273 -0
  774. manigent/web/frontend/src/components/sidebar/MyDeskRailPanel.tsx +351 -0
  775. manigent/web/frontend/src/components/sidebar/PanelShell.tsx +48 -0
  776. manigent/web/frontend/src/components/sidebar/ProjectItem.tsx +161 -0
  777. manigent/web/frontend/src/components/sidebar/ProjectRow.tsx +151 -0
  778. manigent/web/frontend/src/components/sidebar/ProjectsPanel.tsx +80 -0
  779. manigent/web/frontend/src/components/sidebar/PublicPanel.counterpart.test.tsx +85 -0
  780. manigent/web/frontend/src/components/sidebar/PublicPanel.hub.test.tsx +147 -0
  781. manigent/web/frontend/src/components/sidebar/PublicPanel.tsx +271 -0
  782. manigent/web/frontend/src/components/sidebar/RecencyHeader.tsx +17 -0
  783. manigent/web/frontend/src/components/sidebar/ScheduledPanel.tsx +58 -0
  784. manigent/web/frontend/src/components/sidebar/SidebarAvatar.tsx +128 -0
  785. manigent/web/frontend/src/components/sidebar/SidebarRail.tsx +163 -0
  786. manigent/web/frontend/src/components/sidebar/TeamGroup.tsx +67 -0
  787. manigent/web/frontend/src/components/sidebar/dmPrefill.test.ts +69 -0
  788. manigent/web/frontend/src/components/sidebar/dmPrefill.ts +80 -0
  789. manigent/web/frontend/src/components/sidebar/index.ts +18 -0
  790. manigent/web/frontend/src/components/sidebar/railShortcuts.ts +18 -0
  791. manigent/web/frontend/src/components/today/lib.test.ts +70 -0
  792. manigent/web/frontend/src/components/today/lib.ts +296 -0
  793. manigent/web/frontend/src/components/today/today.css +113 -0
  794. manigent/web/frontend/src/components/welcome/RunnerSetup.css +253 -0
  795. manigent/web/frontend/src/components/welcome/RunnerSetup.tsx +148 -0
  796. manigent/web/frontend/src/components/welcome/WelcomePage.css +383 -0
  797. manigent/web/frontend/src/components/welcome/WelcomePage.tsx +329 -0
  798. manigent/web/frontend/src/components/welcome/welcomeCopy.test.ts +382 -0
  799. manigent/web/frontend/src/components/welcome/welcomeCopy.ts +313 -0
  800. manigent/web/frontend/src/config/llmProviders.test.ts +161 -0
  801. manigent/web/frontend/src/config/llmProviders.ts +109 -0
  802. manigent/web/frontend/src/config/providerIds.fixture.json +13 -0
  803. manigent/web/frontend/src/hooks/accountNavigation.test.tsx +138 -0
  804. manigent/web/frontend/src/hooks/index.ts +10 -0
  805. manigent/web/frontend/src/hooks/useActiveWorkBootstrap.ts +86 -0
  806. manigent/web/frontend/src/hooks/useAgentAccess.test.tsx +30 -0
  807. manigent/web/frontend/src/hooks/useAgentAccess.ts +204 -0
  808. manigent/web/frontend/src/hooks/useAgentSampleRequests.ts +27 -0
  809. manigent/web/frontend/src/hooks/useAgentSearch.ts +10 -0
  810. manigent/web/frontend/src/hooks/useAgents.ts +46 -0
  811. manigent/web/frontend/src/hooks/useAnnouncements.test.ts +112 -0
  812. manigent/web/frontend/src/hooks/useAnnouncements.ts +100 -0
  813. manigent/web/frontend/src/hooks/useAssistantAgent.ts +24 -0
  814. manigent/web/frontend/src/hooks/useAutoResizeTextarea.ts +18 -0
  815. manigent/web/frontend/src/hooks/useBridgeDeliveries.test.tsx +79 -0
  816. manigent/web/frontend/src/hooks/useBriefTabs.ts +122 -0
  817. manigent/web/frontend/src/hooks/useChannelThreads.ts +69 -0
  818. manigent/web/frontend/src/hooks/useComposeForm.ts +80 -0
  819. manigent/web/frontend/src/hooks/useComputers.ts +123 -0
  820. manigent/web/frontend/src/hooks/useContextFirstItem.ts +118 -0
  821. manigent/web/frontend/src/hooks/useCreateProject.ts +78 -0
  822. manigent/web/frontend/src/hooks/useDebouncedValue.ts +16 -0
  823. manigent/web/frontend/src/hooks/useDeleteProject.ts +27 -0
  824. manigent/web/frontend/src/hooks/useDeskDispatch.test.tsx +170 -0
  825. manigent/web/frontend/src/hooks/useDeskDispatch.ts +135 -0
  826. manigent/web/frontend/src/hooks/useDeskReadState.test.tsx +85 -0
  827. manigent/web/frontend/src/hooks/useDeskReadState.ts +150 -0
  828. manigent/web/frontend/src/hooks/useDeskRecentlyDone.test.tsx +222 -0
  829. manigent/web/frontend/src/hooks/useDeskRecentlyDone.ts +225 -0
  830. manigent/web/frontend/src/hooks/useDeskTour.ts +37 -0
  831. manigent/web/frontend/src/hooks/useDeskUpdates.channelThreads.test.tsx +91 -0
  832. manigent/web/frontend/src/hooks/useDeskUpdates.hubThreads.test.tsx +93 -0
  833. manigent/web/frontend/src/hooks/useDeskUpdates.ts +353 -0
  834. manigent/web/frontend/src/hooks/useDisconnectIntegration.ts +23 -0
  835. manigent/web/frontend/src/hooks/useDmThreads.ts +99 -0
  836. manigent/web/frontend/src/hooks/useFileDropzone.ts +93 -0
  837. manigent/web/frontend/src/hooks/useFileUpload.ts +38 -0
  838. manigent/web/frontend/src/hooks/useFiles.ts +98 -0
  839. manigent/web/frontend/src/hooks/useHub.test.tsx +237 -0
  840. manigent/web/frontend/src/hooks/useHub.ts +502 -0
  841. manigent/web/frontend/src/hooks/useInstall.ts +82 -0
  842. manigent/web/frontend/src/hooks/useLabels.test.tsx +294 -0
  843. manigent/web/frontend/src/hooks/useLabels.ts +287 -0
  844. manigent/web/frontend/src/hooks/useLogout.ts +49 -0
  845. manigent/web/frontend/src/hooks/useMentionDetection.ts +90 -0
  846. manigent/web/frontend/src/hooks/useMessageAttachments.deskLimit.test.ts +264 -0
  847. manigent/web/frontend/src/hooks/useMessageAttachments.ts +283 -0
  848. manigent/web/frontend/src/hooks/useMessageDeepLink.test.tsx +70 -0
  849. manigent/web/frontend/src/hooks/useMessageDeepLink.ts +32 -0
  850. manigent/web/frontend/src/hooks/useMessageSearch.ts +78 -0
  851. manigent/web/frontend/src/hooks/useModelConfigs.ts +178 -0
  852. manigent/web/frontend/src/hooks/useMyAgents.ts +18 -0
  853. manigent/web/frontend/src/hooks/usePointerReorder.ts +270 -0
  854. manigent/web/frontend/src/hooks/usePrefersReducedMotion.ts +18 -0
  855. manigent/web/frontend/src/hooks/useProjectPlan.ts +36 -0
  856. manigent/web/frontend/src/hooks/useProjectReport.ts +19 -0
  857. manigent/web/frontend/src/hooks/useProjects.ts +104 -0
  858. manigent/web/frontend/src/hooks/useProjectsWithChatrooms.ts +97 -0
  859. manigent/web/frontend/src/hooks/usePublicRequests.ts +89 -0
  860. manigent/web/frontend/src/hooks/useRecentAgents.ts +38 -0
  861. manigent/web/frontend/src/hooks/useRenameProject.test.tsx +185 -0
  862. manigent/web/frontend/src/hooks/useRenameProject.ts +192 -0
  863. manigent/web/frontend/src/hooks/useRoomTrust.ts +56 -0
  864. manigent/web/frontend/src/hooks/useSharing.ts +46 -0
  865. manigent/web/frontend/src/hooks/useSopRecents.ts +55 -0
  866. manigent/web/frontend/src/hooks/useSopSchedules.ts +25 -0
  867. manigent/web/frontend/src/hooks/useSops.ts +99 -0
  868. manigent/web/frontend/src/hooks/useSwitchAccount.ts +41 -0
  869. manigent/web/frontend/src/hooks/useTeamGroups.ts +48 -0
  870. manigent/web/frontend/src/hooks/useThreadOutbox.ts +68 -0
  871. manigent/web/frontend/src/hooks/useTodos.add.test.tsx +89 -0
  872. manigent/web/frontend/src/hooks/useTodos.linkProject.test.tsx +115 -0
  873. manigent/web/frontend/src/hooks/useTodos.saveDraft.test.tsx +188 -0
  874. manigent/web/frontend/src/hooks/useTodos.ts +396 -0
  875. manigent/web/frontend/src/hooks/useUntrustedBodies.test.tsx +87 -0
  876. manigent/web/frontend/src/hooks/useUntrustedBodies.ts +51 -0
  877. manigent/web/frontend/src/hooks/useWebSocket.briefTab.test.tsx +148 -0
  878. manigent/web/frontend/src/hooks/useWebSocket.channelThreads.test.tsx +82 -0
  879. manigent/web/frontend/src/hooks/useWebSocket.hostSync.test.tsx +92 -0
  880. manigent/web/frontend/src/hooks/useWebSocket.hub.test.tsx +114 -0
  881. manigent/web/frontend/src/hooks/useWebSocket.labelSync.test.tsx +168 -0
  882. manigent/web/frontend/src/hooks/useWebSocket.plan.test.tsx +168 -0
  883. manigent/web/frontend/src/hooks/useWebSocket.reconnect.test.tsx +166 -0
  884. manigent/web/frontend/src/hooks/useWebSocket.runnerVersions.test.tsx +167 -0
  885. manigent/web/frontend/src/hooks/useWebSocket.ts +495 -0
  886. manigent/web/frontend/src/hooks/useWindowFileDropGuard.ts +33 -0
  887. manigent/web/frontend/src/landing.tsx +11 -0
  888. manigent/web/frontend/src/main.tsx +17 -0
  889. manigent/web/frontend/src/private-beta.tsx +10 -0
  890. manigent/web/frontend/src/public.tsx +42 -0
  891. manigent/web/frontend/src/queryClient.ts +16 -0
  892. manigent/web/frontend/src/stores/activeWorkStore.ts +88 -0
  893. manigent/web/frontend/src/stores/announcementStore.test.ts +217 -0
  894. manigent/web/frontend/src/stores/announcementStore.ts +169 -0
  895. manigent/web/frontend/src/stores/authStore.multiSession.test.ts +304 -0
  896. manigent/web/frontend/src/stores/authStore.test.ts +235 -0
  897. manigent/web/frontend/src/stores/authStore.ts +693 -0
  898. manigent/web/frontend/src/stores/deskLabelViewStore.test.ts +162 -0
  899. manigent/web/frontend/src/stores/deskLabelViewStore.ts +234 -0
  900. manigent/web/frontend/src/stores/deskLinkStore.ts +54 -0
  901. manigent/web/frontend/src/stores/deskStore.test.ts +53 -0
  902. manigent/web/frontend/src/stores/deskStore.ts +93 -0
  903. manigent/web/frontend/src/stores/heldPostsStore.ts +78 -0
  904. manigent/web/frontend/src/stores/mcpAuthStore.ts +52 -0
  905. manigent/web/frontend/src/stores/modelSelectionStore.ts +33 -0
  906. manigent/web/frontend/src/stores/perUserStores.ts +79 -0
  907. manigent/web/frontend/src/stores/runnerVersionStore.test.ts +81 -0
  908. manigent/web/frontend/src/stores/runnerVersionStore.ts +60 -0
  909. manigent/web/frontend/src/stores/skillAuthStore.ts +54 -0
  910. manigent/web/frontend/src/stores/switchReset.test.ts +236 -0
  911. manigent/web/frontend/src/stores/tokenExp.ts +32 -0
  912. manigent/web/frontend/src/stores/uiStore.ts +186 -0
  913. manigent/web/frontend/src/stores/unreadStore.ts +204 -0
  914. manigent/web/frontend/src/stores/unsavedGuard.ts +22 -0
  915. manigent/web/frontend/src/styles/design/fonts/JetBrainsMono-Latin.woff2 +0 -0
  916. manigent/web/frontend/src/styles/design/fonts/Nunito-Latin.woff2 +0 -0
  917. manigent/web/frontend/src/styles/design/fonts/Nunito-LatinExt.woff2 +0 -0
  918. manigent/web/frontend/src/styles/design/tokens.css +160 -0
  919. manigent/web/frontend/src/styles/homepage.css +242 -0
  920. manigent/web/frontend/src/styles/index.css +126 -0
  921. manigent/web/frontend/src/styles/landing.css +52 -0
  922. manigent/web/frontend/src/test/authFixtures.ts +154 -0
  923. manigent/web/frontend/src/test/fetchHarness.ts +203 -0
  924. manigent/web/frontend/src/test/fixtures/channelThreads.smoke.json +42 -0
  925. manigent/web/frontend/src/test/hubFixtures.ts +212 -0
  926. manigent/web/frontend/src/test/queryWrapper.tsx +9 -0
  927. manigent/web/frontend/src/test/suiteHygiene.test.ts +212 -0
  928. manigent/web/frontend/src/types/channelThreadName.test.ts +16 -0
  929. manigent/web/frontend/src/types/index.ts +903 -0
  930. manigent/web/frontend/src/utils/agentAccessCopy.test.ts +53 -0
  931. manigent/web/frontend/src/utils/agentAccessCopy.ts +231 -0
  932. manigent/web/frontend/src/utils/agentDisplay.ts +81 -0
  933. manigent/web/frontend/src/utils/briefSchedule.ts +25 -0
  934. manigent/web/frontend/src/utils/channelThreads.test.ts +109 -0
  935. manigent/web/frontend/src/utils/channelThreads.ts +179 -0
  936. manigent/web/frontend/src/utils/chatroomDisplay.ts +11 -0
  937. manigent/web/frontend/src/utils/deskLinks.test.ts +155 -0
  938. manigent/web/frontend/src/utils/deskLinks.ts +115 -0
  939. manigent/web/frontend/src/utils/deskRecipient.ts +29 -0
  940. manigent/web/frontend/src/utils/formatDate.ts +102 -0
  941. manigent/web/frontend/src/utils/frontDesk.ts +65 -0
  942. manigent/web/frontend/src/utils/hubChannelView.test.ts +178 -0
  943. manigent/web/frontend/src/utils/hubChannelView.ts +307 -0
  944. manigent/web/frontend/src/utils/hubDelivery.test.ts +105 -0
  945. manigent/web/frontend/src/utils/hubDelivery.ts +137 -0
  946. manigent/web/frontend/src/utils/hubDisplay.test.ts +157 -0
  947. manigent/web/frontend/src/utils/hubDisplay.ts +221 -0
  948. manigent/web/frontend/src/utils/hubThread.test.ts +184 -0
  949. manigent/web/frontend/src/utils/hubThread.ts +247 -0
  950. manigent/web/frontend/src/utils/labelErrors.test.ts +127 -0
  951. manigent/web/frontend/src/utils/labelErrors.ts +123 -0
  952. manigent/web/frontend/src/utils/labelView.test.ts +846 -0
  953. manigent/web/frontend/src/utils/labelView.ts +760 -0
  954. manigent/web/frontend/src/utils/legalUrls.ts +11 -0
  955. manigent/web/frontend/src/utils/messageSender.test.ts +32 -0
  956. manigent/web/frontend/src/utils/messageSender.ts +29 -0
  957. manigent/web/frontend/src/utils/oauthScopeCopy.ts +73 -0
  958. manigent/web/frontend/src/utils/personalize.ts +49 -0
  959. manigent/web/frontend/src/utils/reachableAgents.test.ts +36 -0
  960. manigent/web/frontend/src/utils/reachableAgents.ts +24 -0
  961. manigent/web/frontend/src/utils/recencyBuckets.ts +61 -0
  962. manigent/web/frontend/src/utils/roomTrust.test.ts +94 -0
  963. manigent/web/frontend/src/utils/roomTrust.ts +111 -0
  964. manigent/web/frontend/src/utils/snippetHighlight.test.ts +63 -0
  965. manigent/web/frontend/src/utils/snippetHighlight.ts +42 -0
  966. manigent/web/frontend/src/utils/sopRecipient.ts +37 -0
  967. manigent/web/frontend/src/utils/sopRequest.test.ts +91 -0
  968. manigent/web/frontend/src/utils/sopRequest.ts +68 -0
  969. manigent/web/frontend/src/utils/sopSchedule.ts +26 -0
  970. manigent/web/frontend/src/utils/sopSection.test.ts +148 -0
  971. manigent/web/frontend/src/utils/sopSection.ts +143 -0
  972. manigent/web/frontend/src/utils/textSearch.test.ts +60 -0
  973. manigent/web/frontend/src/utils/textSearch.ts +43 -0
  974. manigent/web/frontend/src/utils/thoughtPartner.ts +26 -0
  975. manigent/web/frontend/src/utils/threadName.test.ts +131 -0
  976. manigent/web/frontend/src/utils/threadName.ts +122 -0
  977. manigent/web/frontend/src/utils/ticketView.test.ts +296 -0
  978. manigent/web/frontend/src/utils/ticketView.ts +175 -0
  979. manigent/web/frontend/src/utils/todoAttachments.desiredState.test.ts +180 -0
  980. manigent/web/frontend/src/utils/todoAttachments.ts +126 -0
  981. manigent/web/frontend/src/utils/todoDraft.test.ts +84 -0
  982. manigent/web/frontend/src/utils/todoDraft.ts +46 -0
  983. manigent/web/frontend/src/utils/todoSearch.test.ts +82 -0
  984. manigent/web/frontend/src/utils/todoSearch.ts +43 -0
  985. manigent/web/frontend/src/utils/versionMatch.test.ts +295 -0
  986. manigent/web/frontend/src/utils/versionMatch.ts +245 -0
  987. manigent/web/frontend/src/vite-env.d.ts +1 -0
  988. manigent/web/frontend/tailwind.config.js +86 -0
  989. manigent/web/frontend/tos.html +188 -0
  990. manigent/web/frontend/tsconfig.dts.json +25 -0
  991. manigent/web/frontend/tsconfig.json +26 -0
  992. manigent/web/frontend/tsconfig.node.json +11 -0
  993. manigent/web/frontend/vite.config.ts +41 -0
  994. manigent/web/frontend/vitest.config.ts +16 -0
  995. manigent/ws_edge/app.py +514 -0
  996. manigent/ws_edge/auth.py +104 -0
  997. manigent/ws_edge/link.py +282 -0
  998. manigent/ws_edge/registry.py +206 -0
  999. manigent/ws_edge/terminal_relay.py +269 -0
  1000. manigent_server-0.1.0.dist-info/METADATA +138 -0
  1001. manigent_server-0.1.0.dist-info/RECORD +1004 -0
  1002. manigent_server-0.1.0.dist-info/WHEEL +4 -0
  1003. manigent_server-0.1.0.dist-info/entry_points.txt +2 -0
  1004. manigent_server-0.1.0.dist-info/licenses/LICENSE +68 -0
@@ -0,0 +1,1203 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
2
+ """
3
+ manigent/server/chat_search_index.py
4
+
5
+ Derived FTS5 search index over chat message bodies, plus the changelog
6
+ subscriber that keeps it current.
7
+
8
+ The index is a single SQLite file at ``{data_dir}/search/chat_index.db``. It
9
+ holds no authoritative data: every row is derived from a room's
10
+ ``CHATS.ndjson``, so the file can be deleted at any time and is rebuilt on the
11
+ next boot by :meth:`ChatSearchIndex.reconcile_all`.
12
+
13
+ Three tables and one FTS5 virtual table (see ``_SCHEMA_SQL``):
14
+
15
+ - ``projects`` mirrors the ACL-relevant columns of a :class:`Project`
16
+ (``created_by``, ``fd_requester_name``, ``surface``) so permission scoping is
17
+ a SQL predicate on the same statement as the match. This is the decision that
18
+ buys the latency target: recomputing the ACL from the filesystem per query
19
+ means ``Project.list_all()`` — 540 ``meta.json`` reads — which costs 30-60x
20
+ the rest of the query. Any change that reintroduces a filesystem walk into
21
+ :meth:`ChatSearchIndex.search` should be rejected on latency grounds alone.
22
+ - ``rooms`` carries the pre-computed ``room_type`` / ``room_label`` the wire
23
+ contract returns, a mirrored ``is_user_communication`` boolean (see below),
24
+ and a ``(size, mtime_ns)`` fingerprint of ``CHATS.ndjson`` so reconcile can
25
+ skip an unchanged room without opening it.
26
+ - ``messages`` + ``messages_fts`` hold the corpus. ``messages_fts`` is an
27
+ external-content table over ``messages``, kept in sync by triggers.
28
+
29
+ The wire format this module produces is pinned by ``SEARCH_API_CONTRACT.md``
30
+ v1.0 — do not change a field name or shape here without updating that document
31
+ and announcing the change.
32
+
33
+ Permission model (contract §5), enforced in :meth:`ChatSearchIndex.search`:
34
+ a message is searchable by user U iff its project P satisfies one of
35
+
36
+ 1. ``P.created_by == U.id``;
37
+ 2. ``P.fd_requester_name == U.username`` AND the room is ``user-communication``
38
+ (the room restriction is load-bearing — a Front Desk requester can read that
39
+ one room and nothing else in the host's project);
40
+ 3. U is a share-token viewer of P.
41
+
42
+ Clause 2's room test is **not** restated in SQL. ``Chatroom.is_user_communication_name``
43
+ is called once per room at index-WRITE time and mirrored into
44
+ ``rooms.is_user_communication``, so the query reads one boolean column and the
45
+ model keeps the single definition of the rule. The first cut of this module
46
+ wrote the clause as ``chatroom_name LIKE 'user-communication%'``, which is
47
+ ASCII-case-INSENSITIVE in SQLite while ``startswith`` is not: a room named
48
+ ``User-communication-notes`` satisfied search's clause 2 but failed the
49
+ room-read endpoint's, leaking snippets across a user boundary. Do not
50
+ reintroduce a room-name predicate in SQL.
51
+
52
+ There is deliberately **no bypass** parameter on the query path: every caller
53
+ is scoped to their own plate. ``is_ack`` rows never enter the corpus.
54
+ """
55
+ from __future__ import annotations
56
+
57
+ import logging
58
+ import os
59
+ import sqlite3
60
+ import threading
61
+ import unicodedata
62
+ from datetime import UTC, datetime
63
+ from functools import lru_cache
64
+ from pathlib import Path
65
+ from typing import TYPE_CHECKING, Iterable, Literal
66
+ from urllib.parse import quote
67
+
68
+ from pydantic import BaseModel, field_serializer
69
+
70
+ from manigent.models.chat_message import ChatMessage
71
+ from manigent.models.chatroom import Chatroom
72
+ from manigent.models.project import Project
73
+ from manigent.sync.changelog import ChangelogEntryType
74
+ from manigent.sync.subscriber import ChangelogSubscriber
75
+
76
+ if TYPE_CHECKING:
77
+ from manigent.models.context import ModelContext
78
+ from manigent.sync.changelog import ChangelogEntry
79
+
80
+ logger = logging.getLogger("manigent.server")
81
+
82
+ SCHEMA_VERSION = 2 # 2: rooms.is_user_communication (mirrors the model predicate)
83
+ CANDIDATE_POOL = 200 # rows BM25 hands to the Python re-ranker
84
+ RECENCY_HALF_LIFE_D = 30.0 # days; recency weight halves every 30d
85
+ RECENCY_FLOOR = 0.25 # oldest possible weight; recency scales 0.25..1.0
86
+ SNIPPET_CHARS = 240 # hard cap on the snippet window (contract §3.5)
87
+ SNIPPET_CORE_CHARS = 200 # initial window before outward word-boundary snapping
88
+ MIN_QUERY_CHARS = 2
89
+ MAX_LIMIT = 50
90
+ MAX_OFFSET = 200
91
+
92
+
93
+ class SearchIndexError(RuntimeError):
94
+ """A query-path failure the caller must surface as 503, not as zero hits.
95
+
96
+ The write path deliberately swallows ``sqlite3.Error`` — a broken search
97
+ index must never take chat down. The QUERY path must not: a locked or
98
+ corrupted index that answered ``hits: []`` would be indistinguishable from
99
+ "nothing matched", and the UI's ``unavailable`` state could never be
100
+ reached (contract §2.3).
101
+ """
102
+
103
+
104
+ _SCHEMA_SQL = """
105
+ CREATE TABLE IF NOT EXISTS projects (
106
+ project_id TEXT PRIMARY KEY,
107
+ project_name TEXT NOT NULL,
108
+ display_name TEXT,
109
+ surface TEXT NOT NULL, -- 'regular' | 'dm' | 'frontdesk'
110
+ created_by TEXT NOT NULL, -- owner user_id
111
+ fd_requester_name TEXT, -- FD requester username, else NULL
112
+ coordinator_name TEXT NOT NULL -- the DM deep link's :agentName
113
+ );
114
+
115
+ CREATE TABLE IF NOT EXISTS rooms (
116
+ project_id TEXT NOT NULL,
117
+ chatroom_name TEXT NOT NULL,
118
+ room_type TEXT NOT NULL, -- 'dm' | 'project'
119
+ room_label TEXT NOT NULL, -- group header the palette renders
120
+ -- Chatroom.is_user_communication_name(chatroom_name), resolved at write time.
121
+ -- The ACL reads THIS, never a room-name pattern; defaults closed (0) so a
122
+ -- writer that forgets it narrows the scope rather than widening it.
123
+ is_user_communication INTEGER NOT NULL DEFAULT 0,
124
+ fingerprint TEXT, -- "{size}:{mtime_ns}" of CHATS.ndjson
125
+ PRIMARY KEY (project_id, chatroom_name)
126
+ );
127
+
128
+ CREATE TABLE IF NOT EXISTS messages (
129
+ rowid INTEGER PRIMARY KEY,
130
+ message_id TEXT NOT NULL,
131
+ project_id TEXT NOT NULL,
132
+ chatroom_name TEXT NOT NULL,
133
+ ts REAL NOT NULL, -- epoch seconds, UTC, sub-second kept
134
+ author_id TEXT NOT NULL,
135
+ author_name TEXT NOT NULL,
136
+ content TEXT NOT NULL,
137
+ UNIQUE (project_id, chatroom_name, message_id) -- makes replay idempotent
138
+ );
139
+ CREATE INDEX IF NOT EXISTS idx_messages_room ON messages(project_id, chatroom_name);
140
+
141
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
142
+ content,
143
+ content = 'messages', -- external content: no duplicate text blob
144
+ content_rowid = 'rowid',
145
+ tokenize = "unicode61 remove_diacritics 2",
146
+ prefix = '2 3' -- typeahead: 2- and 3-char prefixes are O(1)
147
+ );
148
+
149
+ -- External-content FTS5 is NOT auto-maintained; these triggers are what keep
150
+ -- the index and the content table from diverging on any write path.
151
+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
152
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
153
+ END;
154
+ CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
155
+ INSERT INTO messages_fts(messages_fts, rowid, content)
156
+ VALUES ('delete', old.rowid, old.content);
157
+ END;
158
+ CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
159
+ INSERT INTO messages_fts(messages_fts, rowid, content)
160
+ VALUES ('delete', old.rowid, old.content);
161
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
162
+ END;
163
+
164
+ CREATE TABLE IF NOT EXISTS index_meta (key TEXT PRIMARY KEY, value TEXT);
165
+ """
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Wire models — SEARCH_API_CONTRACT.md v1.0, serialized verbatim by the route
170
+ # ---------------------------------------------------------------------------
171
+
172
+ class DeepLink(BaseModel):
173
+ """The click-through target of a hit (contract §4).
174
+
175
+ ``path`` is a complete, URL-encoded, application-relative route the client
176
+ hands straight to ``navigate()``; the structured parts are provided so a
177
+ caller never has to parse it back apart.
178
+ """
179
+ path: str
180
+ kind: Literal["dm", "project_room"]
181
+ project_id: str
182
+ chatroom_name: str
183
+ agent_name: str | None # non-null iff kind == "dm"
184
+ message_id: str
185
+
186
+
187
+ class SearchHit(BaseModel):
188
+ """One ranked message hit (contract §3.2). Every field is always present."""
189
+ message_id: str
190
+ project_id: str
191
+ project_name: str
192
+ room_id: str # == chatroom_name (the room's primary key)
193
+ room_type: Literal["dm", "project"]
194
+ room_label: str
195
+ ts: datetime
196
+ author_id: str
197
+ author_name: str
198
+ snippet: str
199
+ snippet_truncated_start: bool
200
+ snippet_truncated_end: bool
201
+ match_offsets: list[tuple[int, int]] # UTF-16 [start, end) into `snippet`
202
+ score: float
203
+ deep_link: DeepLink
204
+
205
+ @field_serializer("ts")
206
+ def _ser_ts(self, value: datetime) -> str:
207
+ """Emit the contract's exact timestamp shape: ISO-8601, UTC, Z-suffixed,
208
+ microsecond precision (``2026-07-26T14:03:11.482000Z``). Pinned here
209
+ rather than left to Pydantic's default so the wire string cannot drift
210
+ with a library upgrade."""
211
+ return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
212
+
213
+
214
+ class SearchResponse(BaseModel):
215
+ """The response envelope (contract §3.1)."""
216
+ query: str
217
+ hits: list[SearchHit]
218
+ limit: int
219
+ offset: int
220
+ has_more: bool
221
+ next_offset: int | None
222
+ indexing: bool # backfill still running -> results may be partial
223
+ took_ms: float
224
+
225
+
226
+ class SearchScope(BaseModel):
227
+ """The caller's searchable scope. Derived server-side from the JWT alone —
228
+ there is no request parameter that widens or narrows it."""
229
+ model_config = {"frozen": True}
230
+
231
+ user_id: str
232
+ username: str
233
+ viewer_project_ids: frozenset[str] = frozenset()
234
+
235
+
236
+ class _SnippetResult(BaseModel):
237
+ """Internal: a snippet window plus its UTF-16 highlight offsets."""
238
+ text: str
239
+ truncated_start: bool
240
+ truncated_end: bool
241
+ offsets: list[tuple[int, int]]
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Text helpers — tokenization, diacritic folding, UTF-16 offsets
246
+ # ---------------------------------------------------------------------------
247
+
248
+ @lru_cache(maxsize=8192)
249
+ def _fold_char(ch: str) -> str:
250
+ """Fold one character to exactly one character.
251
+
252
+ NFD-decompose, drop combining marks, lowercase, then collapse back to a
253
+ single character — a combining mark that folds away becomes ``\\x00``,
254
+ which never matches a query token. Cached because real text reuses a very
255
+ small alphabet.
256
+ """
257
+ stripped = "".join(
258
+ c for c in unicodedata.normalize("NFD", ch) if not unicodedata.combining(c)
259
+ )
260
+ lowered = stripped.lower()
261
+ return lowered[0] if lowered else "\x00"
262
+
263
+
264
+ def _fold(text: str) -> str:
265
+ """Case- and diacritic-fold ``text`` **preserving a 1:1 code-point mapping**
266
+ to the original.
267
+
268
+ The FTS5 tokenizer is ``unicode61 remove_diacritics 2``, so ``café`` and
269
+ ``cafe`` are the same token to SQLite. Highlight offsets, though, have to
270
+ point back into the ORIGINAL string — so folding must not change the
271
+ length, which rules out a plain ``NFKD`` normalize.
272
+
273
+ This runs over the FULL body of every returned hit to locate matches, so it
274
+ is the hottest Python on the query path: a naive per-character loop cost
275
+ ~22 ms of a ~50 ms query against the real corpus. ASCII text — effectively
276
+ all of it — has no diacritics to strip and folds to exactly ``str.lower()``,
277
+ which is one C-level call and length-preserving, so it takes that path.
278
+ """
279
+ if text.isascii():
280
+ return text.lower()
281
+ return "".join(map(_fold_char, text))
282
+
283
+
284
+ def tokenize_query(query: str) -> list[str]:
285
+ """Split raw user input into FTS5 tokens (contract §2.2).
286
+
287
+ Input is split on every non-alphanumeric character, so quotes, ``*``,
288
+ ``(``, ``)``, ``:``, ``^``, ``-`` and the words AND / OR / NOT / NEAR carry
289
+ no special meaning — there is no query DSL in v1 and no way to inject one.
290
+ Tokens come back diacritic- and case-folded to match the tokenizer.
291
+ """
292
+ tokens: list[str] = []
293
+ current: list[str] = []
294
+ for ch in query:
295
+ if ch.isalnum():
296
+ current.append(ch)
297
+ elif current:
298
+ tokens.append("".join(current))
299
+ current = []
300
+ if current:
301
+ tokens.append("".join(current))
302
+ return [_fold(t) for t in tokens if t]
303
+
304
+
305
+ def build_match_expr(tokens: Iterable[str]) -> str:
306
+ """Build the FTS5 MATCH expression: every token quoted (so no FTS5 syntax
307
+ character can escape), joined by implicit AND, with ``*`` appended to the
308
+ LAST token only. ``deep lin`` becomes ``"deep" "lin"*`` — that trailing
309
+ star is what makes this a typeahead rather than a whole-word search."""
310
+ parts = list(tokens)
311
+ quoted = ['"' + t.replace('"', '""') + '"' for t in parts]
312
+ if quoted:
313
+ quoted[-1] += "*"
314
+ return " ".join(quoted)
315
+
316
+
317
+ def _utf16_prefix(text: str) -> list[int]:
318
+ """Cumulative UTF-16 code-unit length at each code-point index.
319
+
320
+ Python string indices are code points; JavaScript's are UTF-16 code units.
321
+ A single emoji before a match shifts every downstream highlight by one, so
322
+ the server converts rather than leaving the client to guess (contract §3.5).
323
+ """
324
+ prefix = [0] * (len(text) + 1)
325
+ for i, ch in enumerate(text):
326
+ prefix[i + 1] = prefix[i] + (2 if ord(ch) > 0xFFFF else 1)
327
+ return prefix
328
+
329
+
330
+ def _is_token_char(ch: str) -> bool:
331
+ return ch.isalnum()
332
+
333
+
334
+ def _find_token_spans(
335
+ folded_content: str, tokens: list[str]
336
+ ) -> list[tuple[int, int]]:
337
+ """Locate every literal occurrence of each token in ``folded_content``.
338
+
339
+ Matches are anchored at a token boundary the way the ``unicode61``
340
+ tokenizer sees one: a match must start at the beginning of a word, and
341
+ every token except the last (which matched as a prefix) must also end at a
342
+ word boundary. Returns merged, ascending, non-overlapping ``[start, end)``
343
+ code-point spans.
344
+ """
345
+ spans: list[tuple[int, int]] = []
346
+ last_index = len(tokens) - 1
347
+ for idx, token in enumerate(tokens):
348
+ if not token:
349
+ continue
350
+ is_prefix = idx == last_index
351
+ start = 0
352
+ while True:
353
+ pos = folded_content.find(token, start)
354
+ if pos < 0:
355
+ break
356
+ start = pos + 1
357
+ if pos > 0 and _is_token_char(folded_content[pos - 1]):
358
+ continue # mid-word: the tokenizer would not have matched here
359
+ end = pos + len(token)
360
+ if not is_prefix and end < len(folded_content) and _is_token_char(
361
+ folded_content[end]
362
+ ):
363
+ continue # a longer word: only the final token matches as a prefix
364
+ spans.append((pos, end))
365
+ if not spans:
366
+ return []
367
+ spans.sort()
368
+ merged = [spans[0]]
369
+ for span in spans[1:]:
370
+ prev_start, prev_end = merged[-1]
371
+ if span[0] <= prev_end:
372
+ merged[-1] = (prev_start, max(prev_end, span[1]))
373
+ else:
374
+ merged.append(span)
375
+ return merged
376
+
377
+
378
+ def _snap_outward(text: str, start: int, end: int, budget: int) -> tuple[int, int]:
379
+ """Widen ``[start, end)`` to the enclosing word boundaries, spending at most
380
+ ``budget`` extra characters in total. Keeps the window from slicing a word
381
+ in half without ever breaching ``SNIPPET_CHARS``."""
382
+ left = start
383
+ while left > 0 and budget > 0 and _is_token_char(text[left - 1]):
384
+ left -= 1
385
+ budget -= 1
386
+ right = end
387
+ while right < len(text) and budget > 0 and _is_token_char(text[right]):
388
+ right += 1
389
+ budget -= 1
390
+ return left, right
391
+
392
+
393
+ def build_snippet(content: str, tokens: list[str]) -> _SnippetResult:
394
+ """Build the returned snippet and the offsets the frontend highlights with.
395
+
396
+ A window of at most ``SNIPPET_CHARS`` characters centred on the first
397
+ match, snapped outward to word boundaries. No ellipsis is embedded — the
398
+ truncation booleans say which side was cut so the client renders the
399
+ ellipsis itself and it never lands inside a highlight range. Offsets are
400
+ UTF-16 code units into the returned snippet, half-open, ascending and
401
+ non-overlapping; an empty list is legal (a tokenizer-level match with no
402
+ literal substring) and is not an error.
403
+ """
404
+ folded = _fold(content)
405
+ spans = _find_token_spans(folded, tokens)
406
+
407
+ if spans:
408
+ first_start, first_end = spans[0]
409
+ centre = (first_start + first_end) // 2
410
+ else:
411
+ centre = 0
412
+ half = SNIPPET_CORE_CHARS // 2
413
+ if len(content) <= SNIPPET_CORE_CHARS:
414
+ start, end = 0, len(content)
415
+ else:
416
+ # Centre the window on the first match, then slide it back inside the
417
+ # string if centring pushed it past either edge.
418
+ start = min(max(0, centre - half), len(content) - SNIPPET_CORE_CHARS)
419
+ end = start + SNIPPET_CORE_CHARS
420
+ start, end = _snap_outward(content, start, end, SNIPPET_CHARS - (end - start))
421
+
422
+ snippet = content[start:end]
423
+ prefix = _utf16_prefix(snippet)
424
+ offsets: list[tuple[int, int]] = []
425
+ for span_start, span_end in spans:
426
+ if span_start >= end or span_end <= start:
427
+ continue
428
+ clipped_start = max(span_start, start) - start
429
+ clipped_end = min(span_end, end) - start
430
+ if clipped_end > clipped_start:
431
+ offsets.append((prefix[clipped_start], prefix[clipped_end]))
432
+
433
+ return _SnippetResult(
434
+ text=snippet,
435
+ truncated_start=start > 0,
436
+ truncated_end=end < len(content),
437
+ offsets=offsets,
438
+ )
439
+
440
+
441
+ def recency_weight(age_days: float) -> float:
442
+ """``0.25 + 0.75 * 0.5^(age_days / 30)`` — multiplicative with a floor.
443
+
444
+ A message from today can outrank an otherwise-identical one from long ago
445
+ by up to 4x, but the floor means recency can never fully bury a much
446
+ stronger older match (contract §3.6).
447
+ """
448
+ decay = 0.5 ** (max(0.0, age_days) / RECENCY_HALF_LIFE_D)
449
+ return RECENCY_FLOOR + (1.0 - RECENCY_FLOOR) * decay
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # ChatSearchIndex
454
+ # ---------------------------------------------------------------------------
455
+
456
+ class ChatSearchIndex:
457
+ """Derived FTS5 index over chat message bodies. Rebuildable at any time.
458
+
459
+ Writes arrive on the event-loop thread from
460
+ :class:`ChatSearchIndexSubscriber` (single-row upserts inside WAL —
461
+ microseconds). Reads arrive on threadpool workers from the route, which is
462
+ why connections are thread-local: ``sqlite3`` connections are not
463
+ thread-safe and ``run_in_threadpool`` hands work to an arbitrary worker.
464
+ """
465
+
466
+ def __init__(self, model_ctx: "ModelContext") -> None:
467
+ self._model_ctx = model_ctx
468
+ self._db_path = Path(model_ctx.base_dir) / "search" / "chat_index.db"
469
+ self._local = threading.local()
470
+ self._available = False
471
+ self._backfilling = False
472
+
473
+ # -- lifecycle ---------------------------------------------------------
474
+
475
+ @property
476
+ def available(self) -> bool:
477
+ """False when FTS5 is missing from the SQLite build or the kill switch
478
+ is set; the route turns this into a 503 rather than an empty result."""
479
+ return self._available
480
+
481
+ @property
482
+ def backfilling(self) -> bool:
483
+ """True while :meth:`reconcile_all` runs. Surfaces as the response's
484
+ ``indexing`` flag so the UI can say "still indexing your history" rather
485
+ than rendering a bare "no results"."""
486
+ return self._backfilling
487
+
488
+ @property
489
+ def db_path(self) -> Path:
490
+ return self._db_path
491
+
492
+ def open(self) -> None:
493
+ """Create the DB and schema, probe FTS5, and honor the kill switch.
494
+
495
+ A missing search feature must never take the chat server down: any
496
+ failure here logs once, leaves :attr:`available` False and makes every
497
+ write method a silent no-op. Idempotent. Recreates the file from
498
+ scratch when the on-disk schema version differs — the index holds no
499
+ authoritative data, so a full rebuild beats a migration ladder.
500
+ """
501
+ if os.environ.get("MANIGENT_SEARCH_DISABLED", "").strip():
502
+ logger.warning("Chat search disabled by MANIGENT_SEARCH_DISABLED")
503
+ self._available = False
504
+ return
505
+ try:
506
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
507
+ conn = self._connect()
508
+ version = self._read_schema_version(conn)
509
+ if version is not None and version != SCHEMA_VERSION:
510
+ logger.info(
511
+ f"Chat search index schema {version} != {SCHEMA_VERSION}; rebuilding"
512
+ )
513
+ conn.close()
514
+ self._reset_thread_state()
515
+ for suffix in ("", "-wal", "-shm"):
516
+ Path(str(self._db_path) + suffix).unlink(missing_ok=True)
517
+ conn = self._connect()
518
+ version = None
519
+ conn.executescript(_SCHEMA_SQL)
520
+ if version is None:
521
+ conn.execute(
522
+ "INSERT OR REPLACE INTO index_meta(key, value) VALUES ('schema_version', ?)",
523
+ (str(SCHEMA_VERSION),),
524
+ )
525
+ conn.commit()
526
+ self._available = True
527
+ except sqlite3.Error as e:
528
+ logger.error(
529
+ f"Chat search index unavailable ({e}); /me/search/messages will 503",
530
+ exc_info=True,
531
+ )
532
+ self._available = False
533
+
534
+ def close(self) -> None:
535
+ conn = getattr(self._local, "conn", None)
536
+ if conn is not None:
537
+ conn.close()
538
+ self._reset_thread_state()
539
+ self._available = False
540
+
541
+ def _reset_thread_state(self) -> None:
542
+ self._local = threading.local()
543
+
544
+ def _connect(self) -> sqlite3.Connection:
545
+ conn = sqlite3.connect(self._db_path)
546
+ conn.row_factory = sqlite3.Row
547
+ conn.execute("PRAGMA journal_mode = WAL")
548
+ conn.execute("PRAGMA synchronous = NORMAL")
549
+ conn.execute("PRAGMA busy_timeout = 5000")
550
+ self._local.conn = conn
551
+ return conn
552
+
553
+ def _conn(self) -> sqlite3.Connection:
554
+ """Per-thread connection (see the class docstring)."""
555
+ conn = getattr(self._local, "conn", None)
556
+ if conn is None:
557
+ conn = self._connect()
558
+ return conn
559
+
560
+ @staticmethod
561
+ def _read_schema_version(conn: sqlite3.Connection) -> int | None:
562
+ try:
563
+ row = conn.execute(
564
+ "SELECT value FROM index_meta WHERE key = 'schema_version'"
565
+ ).fetchone()
566
+ except sqlite3.OperationalError:
567
+ return None # fresh file: index_meta does not exist yet
568
+ if row is None:
569
+ return None
570
+ try:
571
+ return int(row["value"])
572
+ except (TypeError, ValueError):
573
+ return None
574
+
575
+ # -- write path --------------------------------------------------------
576
+
577
+ def upsert_project(self, project: Project) -> None:
578
+ """Mirror the ACL-relevant columns of a project. These columns are the
579
+ ONLY authorization inputs on the query path — see :meth:`search`."""
580
+ if not self._available:
581
+ return
582
+ try:
583
+ conn = self._conn()
584
+ conn.execute(
585
+ """
586
+ INSERT INTO projects
587
+ (project_id, project_name, display_name, surface,
588
+ created_by, fd_requester_name, coordinator_name)
589
+ VALUES (?, ?, ?, ?, ?, ?, ?)
590
+ ON CONFLICT(project_id) DO UPDATE SET
591
+ project_name = excluded.project_name,
592
+ display_name = excluded.display_name,
593
+ surface = excluded.surface,
594
+ created_by = excluded.created_by,
595
+ fd_requester_name = excluded.fd_requester_name,
596
+ coordinator_name = excluded.coordinator_name
597
+ """,
598
+ (
599
+ project.id,
600
+ project.name,
601
+ project.display_name,
602
+ str(project.surface),
603
+ project.created_by or "",
604
+ project.fd_requester_name,
605
+ project.coordinator_name,
606
+ ),
607
+ )
608
+ conn.commit()
609
+ except sqlite3.Error:
610
+ logger.warning(f"Search index: upsert_project({project.id}) failed", exc_info=True)
611
+
612
+ def upsert_room(
613
+ self, project: Project, chatroom_name: str, fingerprint: str | None = None
614
+ ) -> None:
615
+ """Mirror a room, deriving the two pre-computed display fields.
616
+
617
+ ``room_type`` is ``"dm"`` iff the project is DM-shaped AND the room is
618
+ ``user-communication`` — mirroring ``Project.is_dm_shaped`` exactly, so
619
+ the client never has to re-derive DM-ness from a project name.
620
+ ``room_label`` is the coordinator (agent) name for a DM row and the
621
+ chatroom name otherwise (contract §3.4).
622
+
623
+ This is also where clause 2 of the ACL is resolved: the room-name
624
+ predicate is evaluated ONCE here, by the model that owns it, and stored
625
+ as ``is_user_communication`` for :meth:`_candidate_rows` to read as a
626
+ plain column.
627
+ """
628
+ if not self._available:
629
+ return
630
+ is_uc = Chatroom.is_user_communication_name(chatroom_name)
631
+ is_dm = project.is_dm_shaped and is_uc
632
+ room_type = "dm" if is_dm else "project"
633
+ room_label = project.coordinator_name if is_dm else chatroom_name
634
+ try:
635
+ conn = self._conn()
636
+ conn.execute(
637
+ """
638
+ INSERT INTO rooms
639
+ (project_id, chatroom_name, room_type, room_label,
640
+ is_user_communication, fingerprint)
641
+ VALUES (?, ?, ?, ?, ?, ?)
642
+ ON CONFLICT(project_id, chatroom_name) DO UPDATE SET
643
+ room_type = excluded.room_type,
644
+ room_label = excluded.room_label,
645
+ is_user_communication = excluded.is_user_communication,
646
+ fingerprint = COALESCE(excluded.fingerprint, rooms.fingerprint)
647
+ """,
648
+ (
649
+ project.id, chatroom_name, room_type, room_label,
650
+ int(is_uc), fingerprint,
651
+ ),
652
+ )
653
+ conn.commit()
654
+ except sqlite3.Error:
655
+ logger.warning(
656
+ f"Search index: upsert_room({project.id}/{chatroom_name}) failed",
657
+ exc_info=True,
658
+ )
659
+
660
+ def index_message(
661
+ self, project_id: str, chatroom_name: str, message: ChatMessage
662
+ ) -> None:
663
+ """Upsert one message.
664
+
665
+ No-op for ``is_ack`` rows (protocol acknowledgments, never user-authored
666
+ text — the same exclusion ``collect_recent_activity`` applies) and for
667
+ blank content. The upsert is keyed on
668
+ ``(project_id, chatroom_name, message_id)``, so changelog replay and
669
+ backfill converge on the same row.
670
+ """
671
+ if not self._available:
672
+ return
673
+ if message.is_ack or not message.content.strip():
674
+ return
675
+ try:
676
+ conn = self._conn()
677
+ self._insert_message(conn, project_id, chatroom_name, message)
678
+ conn.commit()
679
+ except sqlite3.Error:
680
+ logger.warning(
681
+ f"Search index: index_message({project_id}/{chatroom_name}) failed",
682
+ exc_info=True,
683
+ )
684
+
685
+ @staticmethod
686
+ def _insert_message(
687
+ conn: sqlite3.Connection,
688
+ project_id: str,
689
+ chatroom_name: str,
690
+ message: ChatMessage,
691
+ ) -> None:
692
+ ts = message.ts
693
+ if ts.tzinfo is None:
694
+ ts = ts.replace(tzinfo=UTC)
695
+ conn.execute(
696
+ """
697
+ INSERT INTO messages
698
+ (message_id, project_id, chatroom_name, ts, author_id, author_name, content)
699
+ VALUES (?, ?, ?, ?, ?, ?, ?)
700
+ ON CONFLICT(project_id, chatroom_name, message_id) DO UPDATE SET
701
+ ts = excluded.ts,
702
+ author_id = excluded.author_id,
703
+ author_name = excluded.author_name,
704
+ content = excluded.content
705
+ """,
706
+ (
707
+ message.id,
708
+ project_id,
709
+ chatroom_name,
710
+ ts.timestamp(),
711
+ message.from_participant_id,
712
+ message.from_participant_name,
713
+ message.content,
714
+ ),
715
+ )
716
+
717
+ def drop_room(self, project_id: str, chatroom_name: str) -> None:
718
+ """Delete every message row for a room.
719
+
720
+ Called on CHATROOM_CLEARED — critically, since a user who wipes a room's
721
+ history must not keep finding it in search — and on ROOM_DELETED for
722
+ every room the model layer will actually delete (the subscriber applies
723
+ ``Chatroom.is_system_room_name`` first; see ``_dispatch``).
724
+ """
725
+ if not self._available:
726
+ return
727
+ try:
728
+ conn = self._conn()
729
+ conn.execute(
730
+ "DELETE FROM messages WHERE project_id = ? AND chatroom_name = ?",
731
+ (project_id, chatroom_name),
732
+ )
733
+ conn.execute(
734
+ "UPDATE rooms SET fingerprint = NULL WHERE project_id = ? AND chatroom_name = ?",
735
+ (project_id, chatroom_name),
736
+ )
737
+ conn.commit()
738
+ except sqlite3.Error:
739
+ logger.warning(
740
+ f"Search index: drop_room({project_id}/{chatroom_name}) failed",
741
+ exc_info=True,
742
+ )
743
+
744
+ def drop_project(self, project_id: str) -> None:
745
+ """Delete a project and all of its rooms and messages.
746
+
747
+ Called from ``_delete_project_in_place``, which tears down the
748
+ changelog itself and so is never observed by the subscriber. Without
749
+ this hook a deleted project's messages stay searchable forever — a
750
+ permission leak, not merely staleness.
751
+ """
752
+ if not self._available:
753
+ return
754
+ try:
755
+ conn = self._conn()
756
+ conn.execute("DELETE FROM messages WHERE project_id = ?", (project_id,))
757
+ conn.execute("DELETE FROM rooms WHERE project_id = ?", (project_id,))
758
+ conn.execute("DELETE FROM projects WHERE project_id = ?", (project_id,))
759
+ conn.commit()
760
+ except sqlite3.Error:
761
+ logger.warning(f"Search index: drop_project({project_id}) failed", exc_info=True)
762
+
763
+ # -- backfill / reconcile ---------------------------------------------
764
+
765
+ @staticmethod
766
+ def _fingerprint(path: Path) -> str | None:
767
+ try:
768
+ st = path.stat()
769
+ except OSError:
770
+ return None
771
+ return f"{st.st_size}:{st.st_mtime_ns}"
772
+
773
+ async def reconcile_all(self) -> None:
774
+ """Walk every project and room, re-indexing only what changed.
775
+
776
+ Each room's ``CHATS.ndjson`` is compared against the stored
777
+ ``(size, mtime_ns)`` fingerprint; a room that matches is skipped after a
778
+ single ``stat()`` without ever being opened. This is BOTH the first-run
779
+ backfill of existing history AND the ongoing drift repair — it catches
780
+ anything that reached ``CHATS.ndjson`` without passing the subscriber.
781
+ Yields to the event loop between rooms so an in-progress backfill never
782
+ starves live requests.
783
+ """
784
+ if not self._available:
785
+ return
786
+ import asyncio
787
+
788
+ self._backfilling = True
789
+ indexed_rooms = 0
790
+ indexed_messages = 0
791
+ try:
792
+ try:
793
+ projects = Project.list_all(self._model_ctx)
794
+ except (OSError, ValueError):
795
+ logger.warning("Search index: could not list projects", exc_info=True)
796
+ return
797
+ live_ids = set()
798
+ for project in projects:
799
+ live_ids.add(project.id)
800
+ self.upsert_project(project)
801
+ try:
802
+ room_names = project.chatrooms
803
+ except (OSError, ValueError):
804
+ continue
805
+ for chatroom_name in room_names:
806
+ changed, message_count = self._reconcile_room(project, chatroom_name)
807
+ if changed:
808
+ indexed_rooms += 1
809
+ indexed_messages += message_count
810
+ await asyncio.sleep(0)
811
+ self._prune_missing_projects(live_ids)
812
+ finally:
813
+ self._backfilling = False
814
+ if indexed_rooms:
815
+ logger.info(
816
+ f"Chat search index: reindexed {indexed_messages} message(s) "
817
+ f"across {indexed_rooms} room(s)"
818
+ )
819
+
820
+ def _reconcile_room(self, project: Project, chatroom_name: str) -> tuple[bool, int]:
821
+ """Re-index one room if its CHATS.ndjson fingerprint moved. Returns
822
+ ``(changed, messages_indexed)``."""
823
+ room = Chatroom.get(project.id, chatroom_name, self._model_ctx)
824
+ if room is None:
825
+ return (False, 0)
826
+ fingerprint = self._fingerprint(room.chats_path)
827
+ try:
828
+ conn = self._conn()
829
+ row = conn.execute(
830
+ "SELECT fingerprint FROM rooms WHERE project_id = ? AND chatroom_name = ?",
831
+ (project.id, chatroom_name),
832
+ ).fetchone()
833
+ except sqlite3.Error:
834
+ logger.warning("Search index: fingerprint read failed", exc_info=True)
835
+ return (False, 0)
836
+ if row is not None and row["fingerprint"] == fingerprint and fingerprint is not None:
837
+ return (False, 0)
838
+
839
+ self.upsert_room(project, chatroom_name)
840
+ count = 0
841
+ try:
842
+ entries = room.get_log_entries()
843
+ except (OSError, ValueError):
844
+ logger.warning(
845
+ f"Search index: could not read {project.id}/{chatroom_name}", exc_info=True
846
+ )
847
+ return (False, 0)
848
+ try:
849
+ conn = self._conn()
850
+ with conn: # one transaction per room
851
+ conn.execute(
852
+ "DELETE FROM messages WHERE project_id = ? AND chatroom_name = ?",
853
+ (project.id, chatroom_name),
854
+ )
855
+ for entry in entries:
856
+ if not isinstance(entry, ChatMessage):
857
+ continue
858
+ if entry.is_ack or not entry.content.strip():
859
+ continue
860
+ self._insert_message(conn, project.id, chatroom_name, entry)
861
+ count += 1
862
+ # Normally an ON CONFLICT update of the fingerprint alone: the
863
+ # upsert_room() above already wrote the row. The INSERT branch
864
+ # is only reachable if that call was swallowed by an error.
865
+ conn.execute(
866
+ """
867
+ INSERT INTO rooms (project_id, chatroom_name, room_type, room_label,
868
+ is_user_communication, fingerprint)
869
+ VALUES (?, ?, 'project', ?, ?, ?)
870
+ ON CONFLICT(project_id, chatroom_name)
871
+ DO UPDATE SET fingerprint = excluded.fingerprint
872
+ """,
873
+ (
874
+ project.id, chatroom_name, chatroom_name,
875
+ int(Chatroom.is_user_communication_name(chatroom_name)),
876
+ fingerprint,
877
+ ),
878
+ )
879
+ except sqlite3.Error:
880
+ logger.warning(
881
+ f"Search index: reindex of {project.id}/{chatroom_name} failed", exc_info=True
882
+ )
883
+ return (False, 0)
884
+ return (True, count)
885
+
886
+ def _prune_missing_projects(self, live_ids: set[str]) -> None:
887
+ """Drop index rows for projects that no longer exist on disk — the
888
+ second half of the deletion story, covering any path that removed a
889
+ project without calling :meth:`drop_project`."""
890
+ try:
891
+ conn = self._conn()
892
+ stored = {r["project_id"] for r in conn.execute("SELECT project_id FROM projects")}
893
+ except sqlite3.Error:
894
+ return
895
+ for project_id in stored - live_ids:
896
+ logger.info(f"Chat search index: pruning deleted project {project_id}")
897
+ self.drop_project(project_id)
898
+
899
+ # -- query path --------------------------------------------------------
900
+
901
+ def search(
902
+ self,
903
+ query: str,
904
+ scope: SearchScope,
905
+ *,
906
+ limit: int = 20,
907
+ offset: int = 0,
908
+ ) -> SearchResponse:
909
+ """Ranked message hits across every room ``scope`` can read.
910
+
911
+ Blocking — the route wraps this in ``run_in_threadpool`` so a slow
912
+ query never stalls the single-process event loop (including WebSocket
913
+ traffic).
914
+
915
+ Two-stage ranking: FTS5/BM25 selects a bounded candidate pool in C,
916
+ then Python re-weights it by recency. A single SQL expression is not an
917
+ option because ``exp()`` is not available in a stock CPython SQLite
918
+ build, and a per-row Python UDF is slower than re-ranking 200 rows.
919
+
920
+ The ACL predicate is inlined into the matching statement, so a row the
921
+ caller may not read is never materialized. Every caller gets their own
922
+ plate; there is no bypass parameter.
923
+
924
+ Raises:
925
+ SearchIndexError: the index could not be queried. The route turns
926
+ this into a 503; it is never reported as zero hits.
927
+ """
928
+ started = _now_monotonic_ms()
929
+ limit = max(1, min(limit, MAX_LIMIT))
930
+ offset = max(0, min(offset, MAX_OFFSET))
931
+ tokens = tokenize_query(query)
932
+
933
+ def _empty() -> SearchResponse:
934
+ return SearchResponse(
935
+ query=query, hits=[], limit=limit, offset=offset,
936
+ has_more=False, next_offset=None,
937
+ indexing=self._backfilling,
938
+ took_ms=round(_now_monotonic_ms() - started, 3),
939
+ )
940
+
941
+ # Below the floor after tokenization is a normal typeahead state, not an
942
+ # error: typing one character returns 200 with no hits (contract §2.2).
943
+ if sum(len(t) for t in tokens) < MIN_QUERY_CHARS:
944
+ return _empty()
945
+
946
+ rows = self._candidate_rows(build_match_expr(tokens), scope)
947
+ if not rows:
948
+ return _empty()
949
+
950
+ now = datetime.now(UTC).timestamp()
951
+ ranked = []
952
+ for row in rows:
953
+ # bm25() is negative-is-better; flip it so a bigger score is better.
954
+ relevance = max(0.0, -float(row["rel"]))
955
+ age_days = max(0.0, (now - float(row["ts"])) / 86400.0)
956
+ ranked.append((relevance * recency_weight(age_days), row))
957
+ ranked.sort(key=lambda pair: pair[0], reverse=True)
958
+
959
+ page = ranked[offset:offset + limit]
960
+ has_more = (offset + limit) < len(ranked)
961
+ hits = [self._build_hit(row, score, tokens) for score, row in page]
962
+ return SearchResponse(
963
+ query=query,
964
+ hits=hits,
965
+ limit=limit,
966
+ offset=offset,
967
+ has_more=has_more,
968
+ next_offset=(offset + len(hits)) if has_more else None,
969
+ indexing=self._backfilling,
970
+ took_ms=round(_now_monotonic_ms() - started, 3),
971
+ )
972
+
973
+ def _candidate_rows(self, match_expr: str, scope: SearchScope) -> list[sqlite3.Row]:
974
+ """One statement: MATCH + the ACL predicate + BM25 ordering, bounded to
975
+ ``CANDIDATE_POOL`` rows. Raises :class:`SearchIndexError` on a SQLite
976
+ failure rather than degrading to an empty result."""
977
+ viewer_ids = sorted(scope.viewer_project_ids)
978
+ # The contract §5 three-clause rule. The invariant is that search returns
979
+ # a hit iff the same user could open that room via the room-read endpoint:
980
+ # not narrower, not wider.
981
+ #
982
+ # Clause 2's room test is a mirrored boolean, NOT a room-name pattern:
983
+ # `Chatroom.is_user_communication_name` was already called at index-write
984
+ # time (see upsert_room) and stored in `rooms.is_user_communication`, so
985
+ # the model owns the rule and SQL only reads the answer. A `LIKE` here
986
+ # leaked across a user boundary once — see the module docstring.
987
+ acl = [
988
+ "p.created_by = :user_id",
989
+ "(p.fd_requester_name = :username AND r.is_user_communication = 1)",
990
+ ]
991
+ params: dict[str, object] = {
992
+ "match": match_expr,
993
+ "user_id": scope.user_id,
994
+ "username": scope.username,
995
+ "pool": CANDIDATE_POOL,
996
+ }
997
+ if viewer_ids:
998
+ placeholders = []
999
+ for i, project_id in enumerate(viewer_ids):
1000
+ key = f"viewer{i}"
1001
+ params[key] = project_id
1002
+ placeholders.append(f":{key}")
1003
+ acl.append(f"p.project_id IN ({', '.join(placeholders)})")
1004
+ sql = f"""
1005
+ SELECT m.message_id, m.project_id, m.chatroom_name, m.ts,
1006
+ m.author_id, m.author_name, m.content,
1007
+ r.room_type, r.room_label,
1008
+ p.project_name, p.coordinator_name,
1009
+ bm25(messages_fts) AS rel
1010
+ FROM messages_fts
1011
+ JOIN messages m ON m.rowid = messages_fts.rowid
1012
+ JOIN rooms r ON r.project_id = m.project_id
1013
+ AND r.chatroom_name = m.chatroom_name
1014
+ JOIN projects p ON p.project_id = m.project_id
1015
+ WHERE messages_fts MATCH :match
1016
+ AND ({' OR '.join(acl)})
1017
+ ORDER BY bm25(messages_fts)
1018
+ LIMIT :pool
1019
+ """
1020
+ try:
1021
+ return self._conn().execute(sql, params).fetchall()
1022
+ except sqlite3.Error as e:
1023
+ # NOT swallowed into an empty result: a locked or corrupted index
1024
+ # must be distinguishable from "nothing matched" (contract §2.3).
1025
+ logger.warning("Search index: query failed", exc_info=True)
1026
+ raise SearchIndexError(str(e)) from e
1027
+
1028
+ def _build_hit(self, row: sqlite3.Row, score: float, tokens: list[str]) -> SearchHit:
1029
+ snippet = build_snippet(row["content"], tokens)
1030
+ return SearchHit(
1031
+ message_id=row["message_id"],
1032
+ project_id=row["project_id"],
1033
+ project_name=row["project_name"],
1034
+ room_id=row["chatroom_name"],
1035
+ room_type=row["room_type"],
1036
+ room_label=row["room_label"],
1037
+ ts=datetime.fromtimestamp(float(row["ts"]), tz=UTC),
1038
+ author_id=row["author_id"],
1039
+ author_name=row["author_name"],
1040
+ snippet=snippet.text,
1041
+ snippet_truncated_start=snippet.truncated_start,
1042
+ snippet_truncated_end=snippet.truncated_end,
1043
+ match_offsets=snippet.offsets,
1044
+ score=round(score, 6),
1045
+ deep_link=_build_deep_link(
1046
+ room_type=row["room_type"],
1047
+ project_id=row["project_id"],
1048
+ chatroom_name=row["chatroom_name"],
1049
+ coordinator_name=row["coordinator_name"],
1050
+ message_id=row["message_id"],
1051
+ ),
1052
+ )
1053
+
1054
+
1055
+ def _build_deep_link(
1056
+ *,
1057
+ room_type: str,
1058
+ project_id: str,
1059
+ chatroom_name: str,
1060
+ coordinator_name: str,
1061
+ message_id: str,
1062
+ ) -> DeepLink:
1063
+ """Build the click-through target (contract §4.2).
1064
+
1065
+ Both shapes are routes that already exist in ``App.tsx``:
1066
+ ``dm/:agentName/:threadId`` and ``projects/:projectId/chatrooms/:chatroomName``.
1067
+ A DM thread's ``:threadId`` **is** its ``project_id`` — each thread is its
1068
+ own project — which is how ``ProjectItem.tsx`` and ``DmsPanel.tsx`` already
1069
+ build DM links.
1070
+ """
1071
+ if room_type == "dm":
1072
+ return DeepLink(
1073
+ path=(
1074
+ f"/dm/{quote(coordinator_name, safe='')}/{quote(project_id, safe='')}"
1075
+ f"?msg={quote(message_id, safe='')}"
1076
+ ),
1077
+ kind="dm",
1078
+ project_id=project_id,
1079
+ chatroom_name=chatroom_name,
1080
+ agent_name=coordinator_name,
1081
+ message_id=message_id,
1082
+ )
1083
+ return DeepLink(
1084
+ path=(
1085
+ f"/projects/{quote(project_id, safe='')}"
1086
+ f"/chatrooms/{quote(chatroom_name, safe='')}"
1087
+ f"?msg={quote(message_id, safe='')}"
1088
+ ),
1089
+ kind="project_room",
1090
+ project_id=project_id,
1091
+ chatroom_name=chatroom_name,
1092
+ agent_name=None,
1093
+ message_id=message_id,
1094
+ )
1095
+
1096
+
1097
+ def _now_monotonic_ms() -> float:
1098
+ import time
1099
+
1100
+ return time.perf_counter() * 1000.0
1101
+
1102
+
1103
+ # ---------------------------------------------------------------------------
1104
+ # Changelog subscriber
1105
+ # ---------------------------------------------------------------------------
1106
+
1107
+ class ChatSearchIndexSubscriber(ChangelogSubscriber):
1108
+ """Indexes each entry AFTER ``ModelContextChangelogSubscriber`` has written it.
1109
+
1110
+ Registered directly after ``model_ctx.changelog_subscriber`` in the
1111
+ ``runloop_factory`` list. ``ChangelogRunloop`` calls subscribers in
1112
+ insertion order per entry, so by the time this runs the row is already
1113
+ durable in ``CHATS.ndjson`` — the index mirrors what was just written
1114
+ rather than racing it.
1115
+
1116
+ Must be idempotent (entries are replayed) and must never raise: an
1117
+ exception here would wedge the runloop cursor for a room's chat delivery,
1118
+ and search is strictly less important than chat.
1119
+ """
1120
+
1121
+ def __init__(self, index: ChatSearchIndex, model_ctx: "ModelContext") -> None:
1122
+ self._index = index
1123
+ self._model_ctx = model_ctx
1124
+
1125
+ async def on_entry(
1126
+ self, entry: "ChangelogEntry", project_id: str, project_name: str
1127
+ ) -> None:
1128
+ if not self._index.available:
1129
+ return
1130
+ try:
1131
+ await self._dispatch(entry, project_id)
1132
+ except Exception: # never propagate — see the class docstring
1133
+ logger.warning(
1134
+ f"Search index: subscriber failed on {entry.entry_type} "
1135
+ f"v{entry.version} in {project_id}",
1136
+ exc_info=True,
1137
+ )
1138
+
1139
+ async def _dispatch(self, entry: "ChangelogEntry", project_id: str) -> None:
1140
+ entry_type = entry.entry_type
1141
+ payload = entry.payload
1142
+
1143
+ if entry_type == ChangelogEntryType.MESSAGE:
1144
+ chatroom_name = getattr(payload, "chatroom_name", None)
1145
+ if not chatroom_name:
1146
+ return
1147
+ if getattr(payload, "is_ack", False):
1148
+ return # protocol noise, never user-authored text
1149
+ message = ChatMessage.from_message_payload(payload, version=entry.version)
1150
+ # A room the index has not seen yet (first message after a restart
1151
+ # that missed ROOM_CREATED) would otherwise produce a message row
1152
+ # with no rooms row to join, i.e. an invisible hit.
1153
+ self._ensure_room(project_id, chatroom_name)
1154
+ self._index.index_message(project_id, chatroom_name, message)
1155
+
1156
+ elif entry_type == ChangelogEntryType.PROJECT_CREATED:
1157
+ self._upsert_project(project_id)
1158
+
1159
+ elif entry_type == ChangelogEntryType.ROOM_CREATED:
1160
+ chatroom_name = getattr(payload, "chatroom_name", None)
1161
+ if chatroom_name:
1162
+ self._ensure_room(project_id, chatroom_name)
1163
+
1164
+ elif entry_type == ChangelogEntryType.DISPLAY_NAME_CHANGED:
1165
+ self._upsert_project(project_id)
1166
+
1167
+ elif entry_type == ChangelogEntryType.ROOM_DELETED:
1168
+ chatroom_name = getattr(payload, "chatroom_name", None)
1169
+ # Mirror ``ModelContext._handle_room_deleted``: it REFUSES to delete
1170
+ # the two system rooms, and ``_delete_project_in_place`` relies on
1171
+ # that refusal — it emits ROOM_DELETED for a room in a *different,
1172
+ # surviving* project. Dropping those rows here would silently empty a
1173
+ # live, still-readable room until the next reconcile_all at boot.
1174
+ if chatroom_name and not Chatroom.is_system_room_name(chatroom_name):
1175
+ self._index.drop_room(project_id, chatroom_name)
1176
+
1177
+ elif entry_type == ChangelogEntryType.CHATROOM_CLEARED:
1178
+ # No skip here: a clear is honored for every room, system ones
1179
+ # included. Wiped history that stayed searchable would be the leak.
1180
+ chatroom_name = getattr(payload, "chatroom_name", None)
1181
+ if chatroom_name:
1182
+ self._index.drop_room(project_id, chatroom_name)
1183
+
1184
+ # Everything else — file events, batch events, participant adds,
1185
+ # allowlist updates — carries no indexable text.
1186
+
1187
+ def _project(self, project_id: str) -> Project | None:
1188
+ try:
1189
+ return Project.get(project_id, self._model_ctx)
1190
+ except ValueError:
1191
+ return None # Project.get raises rather than returning None
1192
+
1193
+ def _upsert_project(self, project_id: str) -> None:
1194
+ project = self._project(project_id)
1195
+ if project is not None:
1196
+ self._index.upsert_project(project)
1197
+
1198
+ def _ensure_room(self, project_id: str, chatroom_name: str) -> None:
1199
+ project = self._project(project_id)
1200
+ if project is None:
1201
+ return
1202
+ self._index.upsert_project(project)
1203
+ self._index.upsert_room(project, chatroom_name)