bazilion 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/README.md +53 -152
  2. package/dist/cli.js +2730 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/daemon.js +5888 -0
  5. package/dist/daemon.js.map +1 -0
  6. package/dist/worker.js +1830 -0
  7. package/dist/worker.js.map +1 -0
  8. package/package.json +57 -28
  9. package/.understand-anything/.understandignore +0 -25
  10. package/.understand-anything/fingerprints.json +0 -14267
  11. package/.understand-anything/knowledge-graph.json +0 -18128
  12. package/.understand-anything/meta.json +0 -6
  13. package/CLAUDE.md +0 -164
  14. package/apps/cli/package.json +0 -21
  15. package/apps/cli/src/auth-file.ts +0 -18
  16. package/apps/cli/src/client.ts +0 -37
  17. package/apps/cli/src/columnize.ts +0 -32
  18. package/apps/cli/src/commands/agent.ts +0 -574
  19. package/apps/cli/src/commands/auth.ts +0 -110
  20. package/apps/cli/src/commands/backup.ts +0 -135
  21. package/apps/cli/src/commands/completion.ts +0 -155
  22. package/apps/cli/src/commands/config.ts +0 -95
  23. package/apps/cli/src/commands/doctor.ts +0 -131
  24. package/apps/cli/src/commands/group.ts +0 -132
  25. package/apps/cli/src/commands/inbox.ts +0 -82
  26. package/apps/cli/src/commands/login.ts +0 -73
  27. package/apps/cli/src/commands/memory.ts +0 -106
  28. package/apps/cli/src/commands/profile.ts +0 -259
  29. package/apps/cli/src/commands/provider.ts +0 -170
  30. package/apps/cli/src/commands/send.ts +0 -24
  31. package/apps/cli/src/commands/serve.ts +0 -89
  32. package/apps/cli/src/commands/skill.ts +0 -120
  33. package/apps/cli/src/commands/token.ts +0 -148
  34. package/apps/cli/src/commands/trigger.ts +0 -129
  35. package/apps/cli/src/commands/uninstall.ts +0 -156
  36. package/apps/cli/src/index.ts +0 -196
  37. package/apps/cli/src/paths.ts +0 -12
  38. package/apps/cli/test/agent.test.ts +0 -213
  39. package/apps/cli/test/backup.test.ts +0 -95
  40. package/apps/cli/test/chat.test.ts +0 -539
  41. package/apps/cli/test/columnize.test.ts +0 -29
  42. package/apps/cli/test/completion.test.ts +0 -45
  43. package/apps/cli/test/config-page.test.ts +0 -151
  44. package/apps/cli/test/group.test.ts +0 -74
  45. package/apps/cli/test/helpers.ts +0 -70
  46. package/apps/cli/test/inbox-autodeliver.test.ts +0 -143
  47. package/apps/cli/test/inbox.test.ts +0 -147
  48. package/apps/cli/test/memory.test.ts +0 -69
  49. package/apps/cli/test/profile.test.ts +0 -110
  50. package/apps/cli/test/send.test.ts +0 -30
  51. package/apps/cli/test/server-fixture.ts +0 -212
  52. package/apps/cli/test/session-head.test.ts +0 -45
  53. package/apps/cli/test/skill.test.ts +0 -128
  54. package/apps/cli/test/token.test.ts +0 -109
  55. package/apps/cli/test/trigger.test.ts +0 -245
  56. package/apps/cli/tsconfig.json +0 -4
  57. package/apps/daemon/package.json +0 -31
  58. package/apps/daemon/src/app.ts +0 -41
  59. package/apps/daemon/src/core/agent/archive.ts +0 -8
  60. package/apps/daemon/src/core/agent/delete.ts +0 -30
  61. package/apps/daemon/src/core/agent/resolve.ts +0 -31
  62. package/apps/daemon/src/core/agent/spawn.ts +0 -95
  63. package/apps/daemon/src/core/agent/unarchive.ts +0 -11
  64. package/apps/daemon/src/core/availableModels.ts +0 -58
  65. package/apps/daemon/src/core/db/client.ts +0 -113
  66. package/apps/daemon/src/core/db/migrate.ts +0 -41
  67. package/apps/daemon/src/core/db/migrations/0001_init.sql +0 -179
  68. package/apps/daemon/src/core/group/delete.ts +0 -21
  69. package/apps/daemon/src/core/group/register.ts +0 -68
  70. package/apps/daemon/src/core/index.ts +0 -71
  71. package/apps/daemon/src/core/paths.ts +0 -56
  72. package/apps/daemon/src/core/profile/create.ts +0 -78
  73. package/apps/daemon/src/core/profile/delete.ts +0 -26
  74. package/apps/daemon/src/core/profile/identity.ts +0 -70
  75. package/apps/daemon/src/core/profile/load.ts +0 -45
  76. package/apps/daemon/src/core/profile/seed.ts +0 -74
  77. package/apps/daemon/src/core/profile/templates.ts +0 -60
  78. package/apps/daemon/src/core/profile/update.ts +0 -57
  79. package/apps/daemon/src/core/profile/validate.ts +0 -9
  80. package/apps/daemon/src/core/repos/agents.ts +0 -202
  81. package/apps/daemon/src/core/repos/config.ts +0 -78
  82. package/apps/daemon/src/core/repos/groups.ts +0 -55
  83. package/apps/daemon/src/core/repos/messages.ts +0 -127
  84. package/apps/daemon/src/core/repos/profiles.ts +0 -83
  85. package/apps/daemon/src/core/repos/providerModels.ts +0 -58
  86. package/apps/daemon/src/core/repos/providerState.ts +0 -37
  87. package/apps/daemon/src/core/repos/secrets.ts +0 -145
  88. package/apps/daemon/src/core/repos/skillMeta.ts +0 -49
  89. package/apps/daemon/src/core/repos/triggers.ts +0 -101
  90. package/apps/daemon/src/core/repos/webTokens.ts +0 -87
  91. package/apps/daemon/src/core/secrets.ts +0 -65
  92. package/apps/daemon/src/core/services.ts +0 -264
  93. package/apps/daemon/src/core/skills/discover.ts +0 -28
  94. package/apps/daemon/src/core/skills/import.ts +0 -136
  95. package/apps/daemon/src/core/skills/parse.ts +0 -52
  96. package/apps/daemon/src/core/skills/resolve.ts +0 -50
  97. package/apps/daemon/src/index.ts +0 -45
  98. package/apps/daemon/src/lib/agent-cancel.ts +0 -48
  99. package/apps/daemon/src/lib/agent-id.ts +0 -13
  100. package/apps/daemon/src/lib/agent-turn.ts +0 -56
  101. package/apps/daemon/src/lib/api-key.ts +0 -56
  102. package/apps/daemon/src/lib/auth.ts +0 -41
  103. package/apps/daemon/src/lib/cron.ts +0 -93
  104. package/apps/daemon/src/lib/ctx.ts +0 -80
  105. package/apps/daemon/src/lib/messaging-host.ts +0 -34
  106. package/apps/daemon/src/lib/middleware-auth.ts +0 -52
  107. package/apps/daemon/src/lib/scheduler.ts +0 -294
  108. package/apps/daemon/src/routes/agents.ts +0 -772
  109. package/apps/daemon/src/routes/auth-login.ts +0 -193
  110. package/apps/daemon/src/routes/config.ts +0 -267
  111. package/apps/daemon/src/routes/groups.ts +0 -133
  112. package/apps/daemon/src/routes/messages.ts +0 -29
  113. package/apps/daemon/src/routes/misc.ts +0 -239
  114. package/apps/daemon/src/routes/profiles.ts +0 -197
  115. package/apps/daemon/src/routes/skills.ts +0 -123
  116. package/apps/daemon/src/routes/triggers.ts +0 -29
  117. package/apps/daemon/src/runtime/auth/openai-codex.ts +0 -121
  118. package/apps/daemon/src/runtime/auto-reply/heartbeat.ts +0 -31
  119. package/apps/daemon/src/runtime/index.ts +0 -77
  120. package/apps/daemon/src/runtime/memory/files.ts +0 -103
  121. package/apps/daemon/src/runtime/memory/qmd.ts +0 -152
  122. package/apps/daemon/src/runtime/memory/types.ts +0 -16
  123. package/apps/daemon/src/runtime/pi/events.ts +0 -173
  124. package/apps/daemon/src/runtime/pi/session.ts +0 -536
  125. package/apps/daemon/src/runtime/pi/tools.ts +0 -85
  126. package/apps/daemon/src/runtime/providers/catalog.ts +0 -145
  127. package/apps/daemon/src/runtime/providers/pi-adapter.ts +0 -272
  128. package/apps/daemon/src/runtime/providers/registry.ts +0 -374
  129. package/apps/daemon/src/runtime/providers/retry.ts +0 -176
  130. package/apps/daemon/src/runtime/providers/types.ts +0 -33
  131. package/apps/daemon/src/runtime/session/prompt.ts +0 -83
  132. package/apps/daemon/src/runtime/tools/bootstrap.ts +0 -22
  133. package/apps/daemon/src/runtime/tools/home.ts +0 -114
  134. package/apps/daemon/src/runtime/tools/memory.ts +0 -81
  135. package/apps/daemon/src/runtime/tools/messaging.ts +0 -127
  136. package/apps/daemon/src/runtime/tools/registry.ts +0 -29
  137. package/apps/daemon/src/runtime/tools/types.ts +0 -13
  138. package/apps/daemon/src/runtime/tools/web-extract.ts +0 -110
  139. package/apps/daemon/src/runtime/tools/web-ssrf.ts +0 -245
  140. package/apps/daemon/src/runtime/tools/web.ts +0 -273
  141. package/apps/daemon/src/runtime/worker/entry.ts +0 -221
  142. package/apps/daemon/src/runtime/worker/ipc-protocol.ts +0 -75
  143. package/apps/daemon/src/runtime/worker/spawn.ts +0 -249
  144. package/apps/daemon/test/core/agents.test.ts +0 -319
  145. package/apps/daemon/test/core/available-models.test.ts +0 -63
  146. package/apps/daemon/test/core/config.test.ts +0 -99
  147. package/apps/daemon/test/core/groups.test.ts +0 -63
  148. package/apps/daemon/test/core/helpers.ts +0 -50
  149. package/apps/daemon/test/core/identity.test.ts +0 -85
  150. package/apps/daemon/test/core/migrations.test.ts +0 -83
  151. package/apps/daemon/test/core/profiles.test.ts +0 -182
  152. package/apps/daemon/test/core/provider-models.test.ts +0 -57
  153. package/apps/daemon/test/core/provider-state.test.ts +0 -36
  154. package/apps/daemon/test/core/skill-meta.test.ts +0 -45
  155. package/apps/daemon/test/core/skills.test.ts +0 -271
  156. package/apps/daemon/test/core/triggers.test.ts +0 -182
  157. package/apps/daemon/test/core/web-tokens.test.ts +0 -79
  158. package/apps/daemon/test/cron.test.ts +0 -90
  159. package/apps/daemon/test/runtime/heartbeat.test.ts +0 -34
  160. package/apps/daemon/test/runtime/memory-qmd.test.ts +0 -97
  161. package/apps/daemon/test/runtime/memory.test.ts +0 -78
  162. package/apps/daemon/test/runtime/messaging.test.ts +0 -213
  163. package/apps/daemon/test/runtime/mock-server.ts +0 -65
  164. package/apps/daemon/test/runtime/openai-codex-auth.test.ts +0 -116
  165. package/apps/daemon/test/runtime/providers.test.ts +0 -306
  166. package/apps/daemon/test/runtime/retry.test.ts +0 -191
  167. package/apps/daemon/test/runtime/session-head.test.ts +0 -90
  168. package/apps/daemon/test/runtime/tools-home.test.ts +0 -102
  169. package/apps/daemon/test/runtime/tools-web.test.ts +0 -206
  170. package/apps/daemon/tsconfig.json +0 -4
  171. package/apps/mobile/README.md +0 -60
  172. package/apps/mobile/app/_layout.tsx +0 -58
  173. package/apps/mobile/app/agents/[id]/chat.tsx +0 -486
  174. package/apps/mobile/app/agents/[id]/index.tsx +0 -166
  175. package/apps/mobile/app/agents/index.tsx +0 -212
  176. package/apps/mobile/app/index.tsx +0 -21
  177. package/apps/mobile/app/pair.tsx +0 -226
  178. package/apps/mobile/app/settings.tsx +0 -419
  179. package/apps/mobile/app.json +0 -49
  180. package/apps/mobile/assets/adaptive-icon.png +0 -0
  181. package/apps/mobile/assets/favicon.png +0 -0
  182. package/apps/mobile/assets/icon.png +0 -0
  183. package/apps/mobile/assets/splash-icon.png +0 -0
  184. package/apps/mobile/babel.config.js +0 -6
  185. package/apps/mobile/metro.config.js +0 -28
  186. package/apps/mobile/package.json +0 -44
  187. package/apps/mobile/src/auth.ts +0 -66
  188. package/apps/mobile/src/pair-url.ts +0 -48
  189. package/apps/mobile/src/theme-context.tsx +0 -88
  190. package/apps/mobile/src/theme.ts +0 -135
  191. package/apps/mobile/test/pair-url.test.ts +0 -46
  192. package/apps/mobile/tsconfig.json +0 -23
  193. package/apps/web/components.json +0 -25
  194. package/apps/web/package.json +0 -39
  195. package/apps/web/public/baziu.svg +0 -8
  196. package/apps/web/src/components/AgentTabs.tsx +0 -45
  197. package/apps/web/src/components/BaziuLogo.tsx +0 -21
  198. package/apps/web/src/components/ChatPane.tsx +0 -1033
  199. package/apps/web/src/components/ConfigTabs.tsx +0 -29
  200. package/apps/web/src/components/CopyButton.tsx +0 -68
  201. package/apps/web/src/components/CreateGroupDialog.tsx +0 -127
  202. package/apps/web/src/components/FieldRow.tsx +0 -94
  203. package/apps/web/src/components/Footer.tsx +0 -10
  204. package/apps/web/src/components/PawIcon.tsx +0 -15
  205. package/apps/web/src/components/Sidebar.tsx +0 -287
  206. package/apps/web/src/components/SpawnDialog.tsx +0 -129
  207. package/apps/web/src/components/ThemeToggle.tsx +0 -75
  208. package/apps/web/src/components/TopNav.tsx +0 -34
  209. package/apps/web/src/components/ui/button.tsx +0 -67
  210. package/apps/web/src/components/ui/card.tsx +0 -103
  211. package/apps/web/src/components/ui/checkbox.tsx +0 -31
  212. package/apps/web/src/components/ui/dialog.tsx +0 -168
  213. package/apps/web/src/components/ui/input.tsx +0 -19
  214. package/apps/web/src/components/ui/label.tsx +0 -22
  215. package/apps/web/src/components/ui/radio-group.tsx +0 -44
  216. package/apps/web/src/components/ui/select.tsx +0 -192
  217. package/apps/web/src/components/ui/separator.tsx +0 -26
  218. package/apps/web/src/components/ui/table.tsx +0 -116
  219. package/apps/web/src/components/ui/tabs.tsx +0 -88
  220. package/apps/web/src/components/ui/textarea.tsx +0 -18
  221. package/apps/web/src/lib/auth.ts +0 -50
  222. package/apps/web/src/lib/daemon-client.ts +0 -34
  223. package/apps/web/src/lib/md.ts +0 -45
  224. package/apps/web/src/lib/utils.ts +0 -6
  225. package/apps/web/src/lib/wire-constants.ts +0 -27
  226. package/apps/web/src/routeTree.gen.ts +0 -408
  227. package/apps/web/src/router.tsx +0 -20
  228. package/apps/web/src/routes/__root.tsx +0 -123
  229. package/apps/web/src/routes/agents/$id/inbox.tsx +0 -207
  230. package/apps/web/src/routes/agents/$id/index.tsx +0 -527
  231. package/apps/web/src/routes/agents/$id/triggers.tsx +0 -239
  232. package/apps/web/src/routes/agents/index.tsx +0 -265
  233. package/apps/web/src/routes/api/$.ts +0 -88
  234. package/apps/web/src/routes/config/index.tsx +0 -315
  235. package/apps/web/src/routes/config/services.tsx +0 -49
  236. package/apps/web/src/routes/config/tokens.tsx +0 -192
  237. package/apps/web/src/routes/groups/$id/index.tsx +0 -153
  238. package/apps/web/src/routes/groups/$id/memory.tsx +0 -321
  239. package/apps/web/src/routes/groups/index.tsx +0 -191
  240. package/apps/web/src/routes/index.tsx +0 -133
  241. package/apps/web/src/routes/login.tsx +0 -63
  242. package/apps/web/src/routes/profiles/$id.tsx +0 -549
  243. package/apps/web/src/routes/profiles/index.tsx +0 -458
  244. package/apps/web/src/routes/skills/index.tsx +0 -297
  245. package/apps/web/src/routes/welcome.tsx +0 -61
  246. package/apps/web/src/styles.css +0 -449
  247. package/apps/web/tsconfig.json +0 -25
  248. package/apps/web/vite.config.ts +0 -25
  249. package/biome.json +0 -23
  250. package/docs/agent-engine.md +0 -219
  251. package/docs/architecture.md +0 -627
  252. package/docs/backlog/README.md +0 -42
  253. package/docs/backlog/draft/BAZ-001-a2a-federation-spike.md +0 -125
  254. package/docs/openclaw-reference.md +0 -210
  255. package/packages/api-types/package.json +0 -11
  256. package/packages/api-types/src/entities.ts +0 -146
  257. package/packages/api-types/src/events.ts +0 -55
  258. package/packages/api-types/src/index.ts +0 -488
  259. package/packages/api-types/src/memory.ts +0 -15
  260. package/packages/client/package.json +0 -13
  261. package/packages/client/src/index.ts +0 -117
  262. package/pnpm-workspace.yaml +0 -3
  263. package/tsconfig.base.json +0 -23
  264. package/tsconfig.json +0 -11
  265. package/vitest.config.ts +0 -22
@@ -1,772 +0,0 @@
1
- // /api/agents/* — agent CRUD + lifecycle + sub-resources (group, skills,
2
- // triggers, messages, sessions, chat). Memory is per-group and lives on
3
- // the groups router.
4
- //
5
- // Sub-resources are inlined here rather than split across files because they
6
- // all share `/api/agents/:id/...` and benefit from being adjacent — e.g. the
7
- // chat streaming endpoint and chat/compact next to each other.
8
-
9
- import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
10
- import { join } from 'node:path'
11
- import {
12
- type AttachSkillRequest,
13
- type ChatCompactRequest,
14
- type ChatCompactResponse,
15
- type ChatContextResponse,
16
- type ContextFileEntry,
17
- type ContextGroupEntry,
18
- type ContextSkillEntry,
19
- type ContextToolEntry,
20
- type CreateTriggerRequest,
21
- type ListInboxResponse,
22
- type MoveAgentRequest,
23
- REASONING_LEVELS,
24
- type ReasoningLevel,
25
- type ResolvedSkillsResponse,
26
- type SendMessageRequest,
27
- type SessionHeadResponse,
28
- type SpawnAgentRequest,
29
- type TruncateChatRequest,
30
- type TruncateChatResponse,
31
- } from '@bazilion/api-types'
32
- import { Hono } from 'hono'
33
- import {
34
- agentRepo,
35
- archiveAgent,
36
- deleteAgent,
37
- discoverSkills,
38
- groupRepo,
39
- mergeSecretsIntoEnv,
40
- messageRepo,
41
- providerStateRepo,
42
- resolveAgent,
43
- resolveAgentSkills,
44
- skillMetaRepo,
45
- spawnAgent,
46
- triggerRepo,
47
- unarchiveAgent,
48
- } from '../core/index.ts'
49
- import { cancelAgent } from '../lib/agent-cancel.ts'
50
- import { resolveAgentIdParam } from '../lib/agent-id.ts'
51
- import { runAgentTurn } from '../lib/agent-turn.ts'
52
- import { resolveAgentApiKey } from '../lib/api-key.ts'
53
- import { validateCron } from '../lib/cron.ts'
54
- import { getCtx } from '../lib/ctx.ts'
55
- import { createDbMessagingHost } from '../lib/messaging-host.ts'
56
- import {
57
- buildSystemPrompt,
58
- createBazilionSession,
59
- loadInitialMessages,
60
- loadSessionHead,
61
- piMessagesToProviderView,
62
- qmdBackend,
63
- } from '../runtime/index.ts'
64
-
65
- export const agentsRouter = new Hono()
66
-
67
- // ─── CRUD + lifecycle ────────────────────────────────────────────────────
68
-
69
- agentsRouter.get('/', (c) => {
70
- const includeArchived = c.req.query('includeArchived') === 'true'
71
- const { db, paths, authToken } = getCtx()
72
- return c.json(agentRepo.list(db, { includeArchived }))
73
- })
74
-
75
- agentsRouter.post('/', async (c) => {
76
- const raw = (await c.req.json().catch(() => null)) as
77
- | (Record<string, unknown> & Partial<SpawnAgentRequest>)
78
- | null
79
- if (!raw) return c.json({ error: 'invalid JSON body' }, 400)
80
- const profileId =
81
- typeof raw.profileId === 'string'
82
- ? raw.profileId
83
- : typeof raw.profile === 'string'
84
- ? raw.profile
85
- : ''
86
- if (!profileId) return c.json({ error: 'profileId is required' }, 400)
87
- const name = typeof raw.name === 'string' && raw.name ? raw.name : undefined
88
- const model =
89
- typeof raw.model === 'string' && raw.model
90
- ? raw.model
91
- : typeof raw.modelOverride === 'string' && raw.modelOverride
92
- ? raw.modelOverride
93
- : undefined
94
- const groupId =
95
- typeof raw.groupId === 'string' && raw.groupId
96
- ? raw.groupId
97
- : typeof raw.group === 'string' && raw.group
98
- ? (raw.group as string)
99
- : undefined
100
- let reasoningLevel: ReasoningLevel | undefined
101
- if (typeof raw.reasoningLevel === 'string') {
102
- if (!REASONING_LEVELS.includes(raw.reasoningLevel as ReasoningLevel)) {
103
- return c.json({ error: `invalid reasoningLevel: ${raw.reasoningLevel}` }, 400)
104
- }
105
- reasoningLevel = raw.reasoningLevel as ReasoningLevel
106
- }
107
-
108
- const { db, paths, authToken } = getCtx()
109
- try {
110
- const agent = spawnAgent(db, paths, {
111
- profileId,
112
- name,
113
- modelOverride: model,
114
- reasoningLevel,
115
- groupId,
116
- })
117
- return c.json(agent, 201)
118
- } catch (err) {
119
- return c.json({ error: (err as Error).message }, 400)
120
- }
121
- })
122
-
123
- agentsRouter.get('/:id', (c) => {
124
- const { db, paths, authToken } = getCtx()
125
- const id = resolveAgentIdParam(db, c.req.param('id'))
126
- try {
127
- return c.json(resolveAgent(db, paths, id))
128
- } catch (err) {
129
- return c.json({ error: (err as Error).message }, 404)
130
- }
131
- })
132
-
133
- agentsRouter.patch('/:id', async (c) => {
134
- const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
135
- if (!body) return c.json({ error: 'invalid JSON body' }, 400)
136
- const { db, paths, authToken } = getCtx()
137
- const resolvedId = agentRepo.resolveId(db, c.req.param('id'))
138
- if (!resolvedId) return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
139
-
140
- if (body.name !== undefined) {
141
- if (typeof body.name !== 'string') return c.json({ error: 'name must be a string' }, 400)
142
- const trimmed = body.name.trim()
143
- if (!trimmed) return c.json({ error: 'name cannot be empty' }, 400)
144
- agentRepo.setName(db, resolvedId, trimmed)
145
- }
146
- if (body.reasoningLevel !== undefined) {
147
- if (
148
- typeof body.reasoningLevel !== 'string' ||
149
- !REASONING_LEVELS.includes(body.reasoningLevel as ReasoningLevel)
150
- ) {
151
- return c.json({ error: `invalid reasoningLevel: ${body.reasoningLevel}` }, 400)
152
- }
153
- agentRepo.setReasoningLevel(db, resolvedId, body.reasoningLevel as ReasoningLevel)
154
- }
155
- if (body.modelOverride !== undefined) {
156
- if (body.modelOverride !== null && typeof body.modelOverride !== 'string') {
157
- return c.json({ error: 'modelOverride must be a string or null' }, 400)
158
- }
159
- const value = body.modelOverride === '' ? null : (body.modelOverride as string | null)
160
- agentRepo.setModelOverride(db, resolvedId, value)
161
- }
162
-
163
- const agent = agentRepo.get(db, resolvedId)
164
- if (!agent) return c.json({ error: 'agent vanished after update' }, 404)
165
- return c.json(agent)
166
- })
167
-
168
- agentsRouter.delete('/:id', (c) => {
169
- const { db, paths, authToken } = getCtx()
170
- try {
171
- deleteAgent(db, c.req.param('id'))
172
- return c.body(null, 204)
173
- } catch (err) {
174
- return c.json({ error: (err as Error).message }, 400)
175
- }
176
- })
177
-
178
- agentsRouter.post('/:id/archive', (c) => {
179
- const { db, paths, authToken } = getCtx()
180
- try {
181
- archiveAgent(db, c.req.param('id'))
182
- return c.body(null, 204)
183
- } catch (err) {
184
- return c.json({ error: (err as Error).message }, 400)
185
- }
186
- })
187
-
188
- agentsRouter.post('/:id/unarchive', (c) => {
189
- const { db, paths, authToken } = getCtx()
190
- try {
191
- unarchiveAgent(db, c.req.param('id'))
192
- return c.body(null, 204)
193
- } catch (err) {
194
- return c.json({ error: (err as Error).message }, 400)
195
- }
196
- })
197
-
198
- // ─── Group move ──────────────────────────────────────────────────────────
199
-
200
- agentsRouter.patch('/:id/group', async (c) => {
201
- const body = (await c.req.json().catch(() => null)) as MoveAgentRequest | null
202
- if (!body || typeof body.groupId !== 'string' || !body.groupId) {
203
- return c.json({ error: 'groupId (string) is required' }, 400)
204
- }
205
- const { db, paths, authToken } = getCtx()
206
- let resolved: ReturnType<typeof resolveAgent>
207
- try {
208
- resolved = resolveAgent(db, paths, c.req.param('id'))
209
- } catch {
210
- return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
211
- }
212
- if (!groupRepo.get(db, body.groupId, paths)) {
213
- return c.json({ error: `group not found: ${body.groupId}` }, 404)
214
- }
215
- agentRepo.setGroup(db, resolved.agent.id, body.groupId)
216
- return c.json(resolveAgent(db, paths, resolved.agent.id))
217
- })
218
-
219
- // ─── Skills ──────────────────────────────────────────────────────────────
220
-
221
- agentsRouter.get('/:id/skills', (c) => {
222
- const { db, paths, authToken } = getCtx()
223
- const id = resolveAgentIdParam(db, c.req.param('id'))
224
- const set = resolveAgentSkills(db, paths, id)
225
- const body: ResolvedSkillsResponse = {
226
- resolved: set.resolved.map((s) => {
227
- const meta = skillMetaRepo.get(db, s.name)
228
- return {
229
- name: s.name,
230
- description: s.parsed.frontmatter.description,
231
- source: meta?.source ?? null,
232
- importedAt: meta?.importedAt ?? null,
233
- }
234
- }),
235
- missing: set.missing,
236
- }
237
- return c.json(body)
238
- })
239
-
240
- agentsRouter.post('/:id/skills', async (c) => {
241
- const body = (await c.req.json().catch(() => null)) as AttachSkillRequest | null
242
- if (!body || typeof body.skill !== 'string' || !body.skill) {
243
- return c.json({ error: 'skill is required' }, 400)
244
- }
245
- const { db, paths, authToken } = getCtx()
246
- const agent = agentRepo.get(db, c.req.param('id'))
247
- if (!agent) return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
248
- agentRepo.attachSkill(db, agent.id, body.skill)
249
- return c.body(null, 204)
250
- })
251
-
252
- agentsRouter.delete('/:id/skills/:name', (c) => {
253
- const { db, paths, authToken } = getCtx()
254
- const id = resolveAgentIdParam(db, c.req.param('id'))
255
- agentRepo.detachSkill(db, id, c.req.param('name'))
256
- return c.body(null, 204)
257
- })
258
-
259
- // ─── Triggers ────────────────────────────────────────────────────────────
260
-
261
- agentsRouter.get('/:id/triggers', (c) => {
262
- const { db, paths, authToken } = getCtx()
263
- const agent = agentRepo.get(db, c.req.param('id'))
264
- if (!agent) return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
265
- return c.json({ triggers: triggerRepo.listForAgent(db, agent.id) })
266
- })
267
-
268
- agentsRouter.post('/:id/triggers', async (c) => {
269
- const body = (await c.req.json().catch(() => null)) as CreateTriggerRequest | null
270
- if (!body) return c.json({ error: 'invalid JSON body' }, 400)
271
- if (typeof body.message !== 'string' || !body.message.trim()) {
272
- return c.json({ error: 'message is required' }, 400)
273
- }
274
- const { db, paths, authToken } = getCtx()
275
- const agent = agentRepo.get(db, c.req.param('id'))
276
- if (!agent) return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
277
-
278
- if (body.kind === 'interval') {
279
- if (!Number.isFinite(body.intervalSec) || (body.intervalSec ?? 0) <= 0) {
280
- return c.json({ error: 'intervalSec must be a positive number' }, 400)
281
- }
282
- const trigger = triggerRepo.insert(db, {
283
- agentId: agent.id,
284
- kind: 'interval',
285
- intervalSec: Math.floor(body.intervalSec as number),
286
- cronExpr: null,
287
- message: body.message,
288
- enabled: body.enabled,
289
- })
290
- return c.json({ trigger }, 201)
291
- }
292
-
293
- if (body.kind === 'cron') {
294
- if (typeof body.cronExpr !== 'string' || !body.cronExpr.trim()) {
295
- return c.json({ error: 'cronExpr is required for kind=cron' }, 400)
296
- }
297
- try {
298
- validateCron(body.cronExpr)
299
- } catch (err) {
300
- return c.json({ error: `invalid cron: ${(err as Error).message}` }, 400)
301
- }
302
- const trigger = triggerRepo.insert(db, {
303
- agentId: agent.id,
304
- kind: 'cron',
305
- intervalSec: null,
306
- cronExpr: body.cronExpr.trim(),
307
- message: body.message,
308
- enabled: body.enabled,
309
- })
310
- return c.json({ trigger }, 201)
311
- }
312
-
313
- return c.json({ error: `invalid kind: ${body.kind}` }, 400)
314
- })
315
-
316
- // ─── Messages ────────────────────────────────────────────────────────────
317
-
318
- agentsRouter.get('/:id/messages', (c) => {
319
- const { db, paths, authToken } = getCtx()
320
- const agent = agentRepo.get(db, c.req.param('id'))
321
- if (!agent) return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
322
- const unreadOnly = c.req.query('unread') === '1'
323
- const body: ListInboxResponse = {
324
- messages: messageRepo.listInbox(db, agent.id, { unreadOnly }),
325
- }
326
- return c.json(body)
327
- })
328
-
329
- agentsRouter.post('/:id/messages', async (c) => {
330
- const body = (await c.req.json().catch(() => null)) as SendMessageRequest | null
331
- if (
332
- !body ||
333
- typeof body.from !== 'string' ||
334
- !body.from ||
335
- !body.payload ||
336
- typeof body.payload.text !== 'string'
337
- ) {
338
- return c.json({ error: 'from and payload.text are required' }, 400)
339
- }
340
- const { db, paths, authToken } = getCtx()
341
- const fromAgent = agentRepo.get(db, body.from)
342
- if (!fromAgent) return c.json({ error: `agent not found: ${body.from}` }, 404)
343
- const toAgent = agentRepo.get(db, c.req.param('id'))
344
- if (!toAgent) return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
345
- if (body.replyTo && !messageRepo.get(db, body.replyTo)) {
346
- return c.json({ error: `reply target not found: ${body.replyTo}` }, 404)
347
- }
348
- const msg = messageRepo.send(db, {
349
- from: fromAgent.id,
350
- to: toAgent.id,
351
- payload: JSON.stringify({ text: body.payload.text }),
352
- replyTo: body.replyTo ?? null,
353
- })
354
- return c.json(msg, 201)
355
- })
356
-
357
- // ─── Cancel ──────────────────────────────────────────────────────────────
358
-
359
- // Aborts the agent's currently-running turn, if any. Returns 204 on a
360
- // successful abort, 409 when the agent is idle. Cancellation drives off the
361
- // in-memory agent-cancel registry, which is also what the scheduler probes
362
- // to skip overlapping inbox wakes / triggers.
363
- agentsRouter.post('/:id/cancel', (c) => {
364
- const { db, paths, authToken } = getCtx()
365
- const id = resolveAgentIdParam(db, c.req.param('id'))
366
- const cancelled = cancelAgent(id)
367
- if (!cancelled) return c.json({ error: 'agent has no active turn' }, 409)
368
- return c.body(null, 204)
369
- })
370
-
371
- // ─── Sessions ────────────────────────────────────────────────────────────
372
-
373
- agentsRouter.get('/:id/sessions/head', (c) => {
374
- const { db, paths, authToken } = getCtx()
375
- let resolved: ReturnType<typeof resolveAgent>
376
- try {
377
- resolved = resolveAgent(db, paths, c.req.param('id'))
378
- } catch {
379
- return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
380
- }
381
- const head: SessionHeadResponse = loadSessionHead(resolved, paths)
382
- return c.json(head)
383
- })
384
-
385
- /**
386
- * Returns the agent's prior transcript flattened to ProviderMessage[].
387
- * SSR loaders use it to render the chat history on first paint without
388
- * touching pi or the filesystem from the web process.
389
- */
390
- agentsRouter.get('/:id/sessions/messages', (c) => {
391
- const { db, paths, authToken } = getCtx()
392
- let resolved: ReturnType<typeof resolveAgent>
393
- try {
394
- resolved = resolveAgent(db, paths, c.req.param('id'))
395
- } catch {
396
- return c.json({ error: `agent not found: ${c.req.param('id')}` }, 404)
397
- }
398
- const messages = piMessagesToProviderView(loadInitialMessages(resolved, paths))
399
- return c.json({ messages })
400
- })
401
-
402
- // ─── Chat ────────────────────────────────────────────────────────────────
403
-
404
- /**
405
- * Streaming chat endpoint. Server-authoritative: prior history is read by
406
- * pi's SessionManager from the agent's JSONL session file. The client sends
407
- * only `{ message }`. Response is NDJSON-encoded `ChatFrame`s.
408
- */
409
- agentsRouter.post('/:id/chat', async (c) => {
410
- const { db, paths, authToken } = getCtx()
411
- const id = resolveAgentIdParam(db, c.req.param('id'))
412
-
413
- let body: { message?: string }
414
- try {
415
- body = (await c.req.json()) as { message?: string }
416
- } catch {
417
- return c.json({ error: 'invalid JSON body' }, 400)
418
- }
419
- const message = body.message
420
- if (!message || typeof message !== 'string') {
421
- return c.json({ error: 'message is required' }, 400)
422
- }
423
-
424
- const stream = new ReadableStream({
425
- async start(controller) {
426
- const encoder = new TextEncoder()
427
- try {
428
- for await (const frame of runAgentTurn(id, message)) {
429
- try {
430
- controller.enqueue(encoder.encode(`${JSON.stringify(frame)}\n`))
431
- } catch {
432
- // client disconnected — keep draining so state gets saved
433
- }
434
- }
435
- } catch (err) {
436
- try {
437
- controller.enqueue(
438
- encoder.encode(`${JSON.stringify({ kind: 'fatal', error: (err as Error).message })}\n`),
439
- )
440
- } catch {}
441
- }
442
- try {
443
- controller.close()
444
- } catch {}
445
- },
446
- })
447
-
448
- return new Response(stream, {
449
- headers: {
450
- 'content-type': 'application/x-ndjson',
451
- 'cache-control': 'no-cache',
452
- 'x-content-type-options': 'nosniff',
453
- },
454
- })
455
- })
456
-
457
- agentsRouter.post('/:id/chat/compact', async (c) => {
458
- let body: ChatCompactRequest = {}
459
- if (c.req.header('content-length') !== '0') {
460
- try {
461
- const parsed = (await c.req.json()) as ChatCompactRequest | null
462
- if (parsed && typeof parsed === 'object') body = parsed
463
- } catch {
464
- // empty body is allowed — all fields optional
465
- }
466
- }
467
-
468
- const { db, paths, authToken } = getCtx()
469
- const id = resolveAgentIdParam(db, c.req.param('id'))
470
- if (!agentRepo.get(db, id)) return c.json({ error: 'agent not found' }, 404)
471
-
472
- const resolved = resolveAgent(db, paths, id)
473
- const memory = qmdBackend(join(resolved.group.path, 'memory'))
474
- await memory.init()
475
- const env = mergeSecretsIntoEnv(db, authToken)
476
-
477
- const apiKeyResolution = await resolveAgentApiKey(db, authToken, resolved, {
478
- withRefresher: true,
479
- })
480
- const handle = await createBazilionSession({
481
- agent: resolved,
482
- paths,
483
- env,
484
- memory,
485
- enabledProviders: providerStateRepo.listEnabled(db),
486
- messagingHost: createDbMessagingHost(db),
487
- ...apiKeyResolution,
488
- })
489
- try {
490
- const entriesBefore = handle.session.sessionManager.getEntries().length
491
- const result = await handle.session.compact(body.customInstructions)
492
- const entriesAfter = handle.session.sessionManager.getEntries().length
493
-
494
- let keptTail = 0
495
- if (result.firstKeptEntryId) {
496
- const branch = handle.session.sessionManager.getBranch()
497
- const idx = branch.findIndex((e) => e.id === result.firstKeptEntryId)
498
- if (idx >= 0) for (const e of branch.slice(idx)) if (e.type === 'message') keptTail++
499
- }
500
-
501
- const tokensAfter = handle.session.getContextUsage()?.tokens ?? 0
502
-
503
- const resp: ChatCompactResponse = {
504
- before: entriesBefore,
505
- after: entriesAfter,
506
- summarized: Math.max(0, entriesBefore - entriesAfter + 1),
507
- keptTail,
508
- tokensBefore: result.tokensBefore,
509
- tokensAfter,
510
- summary: result.summary,
511
- }
512
- return c.json(resp)
513
- } catch (err) {
514
- return c.json({ error: (err as Error).message }, 502)
515
- } finally {
516
- handle.dispose()
517
- }
518
- })
519
-
520
- agentsRouter.get('/:id/chat/context', async (c) => {
521
- const { db, paths, authToken } = getCtx()
522
- const id = resolveAgentIdParam(db, c.req.param('id'))
523
- if (!agentRepo.get(db, id)) return c.json({ error: 'agent not found' }, 404)
524
-
525
- const resolved = resolveAgent(db, paths, id)
526
-
527
- const files: ContextFileEntry[] = []
528
- for (const file of CONTEXT_FILE_ORDER) {
529
- const path = join(resolved.agent.dir, file)
530
- if (!existsSync(path)) continue
531
- const content = readFileSync(path, 'utf8').trimEnd()
532
- if (!content) continue
533
- const chars = content.length + file.length + 6
534
- files.push({ name: file, chars, tokens: estimateTokens(chars) })
535
- }
536
- const systemPromptText = buildSystemPrompt(resolved)
537
- const systemPromptChars = systemPromptText.length
538
-
539
- const skillsListChars =
540
- resolved.skills.length > 0
541
- ? `# Available Skills\n\nYou have access to the following skills: ${resolved.skills.join(', ')}.`
542
- .length
543
- : 0
544
- const groupLines = [
545
- '# Group',
546
- '',
547
- `- ${resolved.group.id} (${resolved.group.name}): ${resolved.group.path}`,
548
- '',
549
- 'Your group is where work product lives — code, docs, artefacts, shared scratch. It may be shared with other agents in the same group. Your coding tools (`read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`) are rooted at the group directory. Never use these tools to edit your identity/soul/behaviour files — those live in your home and are reached via `home_write` / `home_read`.',
550
- ]
551
- const groupListChars = groupLines.join('\n').length
552
- const userMdChars = resolved.group.userMd.trim()
553
- ? `# About the User\n\nRead-only context about the human you're working with in this group. You cannot edit this — if it's wrong, say so and they will update it.\n\n${resolved.group.userMd.trim()}`
554
- .length
555
- : 0
556
- const memoryHintChars =
557
- '# Memory\n\nYou have a persistent memory backend. Use `memory_write` to remember things across sessions, and `memory_search` / `memory_read` / `memory_list` to recall them. Always check memory at the start of a session if the user might have told you something important before.'
558
- .length
559
-
560
- const memory = qmdBackend(join(resolved.group.path, 'memory'))
561
- await memory.init()
562
- const env = mergeSecretsIntoEnv(db, authToken)
563
- const apiKeyResolution = await resolveAgentApiKey(db, authToken, resolved, {
564
- withRefresher: true,
565
- })
566
- const handle = await createBazilionSession({
567
- agent: resolved,
568
- paths,
569
- env,
570
- memory,
571
- enabledProviders: providerStateRepo.listEnabled(db),
572
- messagingHost: createDbMessagingHost(db),
573
- ...apiKeyResolution,
574
- })
575
-
576
- try {
577
- const toolInfos = handle.session.getAllTools()
578
- let toolsSchemaChars = 0
579
- let toolsListChars = 0
580
- const toolEntries: ContextToolEntry[] = []
581
- for (const info of toolInfos) {
582
- const schemaJson = JSON.stringify(info.parameters ?? {})
583
- const schemaChars = schemaJson.length
584
- const descriptionChars = info.description.length
585
- toolsSchemaChars += schemaChars
586
- toolsListChars += info.name.length + descriptionChars + 3
587
- toolEntries.push({
588
- name: info.name,
589
- schemaChars,
590
- descriptionChars,
591
- paramCount: countProperties(info.parameters),
592
- })
593
- }
594
- toolEntries.sort((a, b) => b.schemaChars - a.schemaChars)
595
-
596
- const installed = discoverSkills(paths)
597
- const skillEntries: ContextSkillEntry[] = []
598
- for (const name of resolved.skills) {
599
- const match = installed.find((s) => s.name === name)
600
- let blockChars = name.length + 2
601
- if (match) {
602
- try {
603
- blockChars = readFileSync(match.skillFile, 'utf8').length
604
- } catch {}
605
- }
606
- skillEntries.push({ name, blockChars })
607
- }
608
- skillEntries.sort((a, b) => b.blockChars - a.blockChars)
609
-
610
- const group: ContextGroupEntry = {
611
- id: resolved.group.id,
612
- name: resolved.group.name,
613
- path: resolved.group.path,
614
- userMdChars: resolved.group.userMd.length,
615
- }
616
-
617
- const stats = handle.session.getSessionStats()
618
- const historyChars = stats.tokens.total * 4
619
- const messageEntries = stats.userMessages + stats.assistantMessages + stats.toolResults
620
- const compactionEntries = handle.session.sessionManager
621
- .getEntries()
622
- .filter((e) => e.type === 'compaction').length
623
- const contextUsage = handle.session.getContextUsage()
624
- const historyTokens = contextUsage?.tokens ?? stats.tokens.total
625
-
626
- const detail = c.req.query('detail') === '1' || c.req.query('json') === '1'
627
- const CAP = 30
628
- const toolEntriesOut = detail ? toolEntries : toolEntries.slice(0, CAP)
629
- const skillEntriesOut = detail ? skillEntries : skillEntries.slice(0, CAP)
630
-
631
- const totalsChars = systemPromptChars + toolsSchemaChars + historyChars
632
- const resp: ChatContextResponse = {
633
- agentId: resolved.agent.id,
634
- model: resolved.model,
635
- systemPrompt: {
636
- chars: systemPromptChars,
637
- tokens: estimateTokens(systemPromptChars),
638
- files,
639
- skillsListChars,
640
- groupListChars,
641
- userMdChars,
642
- memoryHintChars,
643
- },
644
- tools: {
645
- count: toolInfos.length,
646
- listChars: toolsListChars,
647
- schemaChars: toolsSchemaChars,
648
- entries: toolEntriesOut,
649
- },
650
- skills: {
651
- count: resolved.skills.length,
652
- entries: skillEntriesOut,
653
- },
654
- group,
655
- history: {
656
- messageEntries,
657
- compactionEntries,
658
- chars: historyChars,
659
- bytes: historyChars,
660
- tokensEstimate: historyTokens,
661
- },
662
- totals: {
663
- chars: totalsChars,
664
- tokens: estimateTokens(totalsChars),
665
- },
666
- }
667
- return c.json(resp)
668
- } finally {
669
- handle.dispose()
670
- }
671
- })
672
-
673
- agentsRouter.post('/:id/chat/reset', (c) => {
674
- const { db, paths, authToken } = getCtx()
675
- const id = resolveAgentIdParam(db, c.req.param('id'))
676
- const agent = agentRepo.get(db, id)
677
- if (!agent) return c.json({ error: 'agent not found' }, 404)
678
-
679
- const sessionsDir = join(paths.agentDir(agent.id), 'sessions')
680
- let deleted = 0
681
- if (existsSync(sessionsDir)) {
682
- for (const file of readdirSync(sessionsDir)) {
683
- if (!file.endsWith('.jsonl')) continue
684
- try {
685
- rmSync(join(sessionsDir, file))
686
- deleted++
687
- } catch {
688
- // best-effort
689
- }
690
- }
691
- }
692
- return c.json({ ok: true, deletedSessionFiles: deleted })
693
- })
694
-
695
- agentsRouter.post('/:id/chat/truncate', async (c) => {
696
- let body: TruncateChatRequest
697
- try {
698
- body = (await c.req.json()) as TruncateChatRequest
699
- } catch {
700
- return c.json({ error: 'invalid JSON body' }, 400)
701
- }
702
- const keep = Number(body.keepCount)
703
- if (!Number.isFinite(keep) || keep < 0 || !Number.isInteger(keep)) {
704
- return c.json({ error: 'keepCount must be a non-negative integer' }, 400)
705
- }
706
-
707
- const { db, paths, authToken } = getCtx()
708
- const id = resolveAgentIdParam(db, c.req.param('id'))
709
- if (!agentRepo.get(db, id)) return c.json({ error: 'agent not found' }, 404)
710
-
711
- const resolved = resolveAgent(db, paths, id)
712
- const memory = qmdBackend(join(resolved.group.path, 'memory'))
713
- await memory.init()
714
- const env = mergeSecretsIntoEnv(db, authToken)
715
-
716
- const apiKeyResolution = await resolveAgentApiKey(db, authToken, resolved, {
717
- withRefresher: true,
718
- })
719
- const handle = await createBazilionSession({
720
- agent: resolved,
721
- paths,
722
- env,
723
- memory,
724
- enabledProviders: providerStateRepo.listEnabled(db),
725
- messagingHost: createDbMessagingHost(db),
726
- ...apiKeyResolution,
727
- })
728
- try {
729
- const branch = handle.session.sessionManager.getBranch()
730
- const messageEntries = branch.filter((e) => e.type === 'message')
731
- const before = messageEntries.length
732
- const target = Math.max(0, Math.min(keep, before))
733
-
734
- if (target === before) {
735
- return c.json({ before, after: before } satisfies TruncateChatResponse)
736
- }
737
-
738
- if (target === 0) {
739
- handle.session.sessionManager.resetLeaf()
740
- } else {
741
- const lastKept = messageEntries[target - 1]
742
- if (!lastKept) return c.json({ error: 'internal: missing target entry' }, 500)
743
- handle.session.sessionManager.branch(lastKept.id)
744
- }
745
-
746
- return c.json({ before, after: target } satisfies TruncateChatResponse)
747
- } finally {
748
- handle.dispose()
749
- }
750
- })
751
-
752
- // ─── helpers ─────────────────────────────────────────────────────────────
753
-
754
- const CONTEXT_FILE_ORDER = [
755
- 'AGENTS.md',
756
- 'SOUL.md',
757
- 'TOOLS.md',
758
- 'IDENTITY.md',
759
- 'HEARTBEAT.md',
760
- 'BOOTSTRAP.md',
761
- ] as const
762
-
763
- function estimateTokens(chars: number): number {
764
- return Math.ceil(Math.max(0, chars) / 4)
765
- }
766
-
767
- function countProperties(schema: unknown): number | null {
768
- if (!schema || typeof schema !== 'object') return null
769
- const props = (schema as { properties?: unknown }).properties
770
- if (!props || typeof props !== 'object') return null
771
- return Object.keys(props as Record<string, unknown>).length
772
- }