@awiki/cli 0.0.1-beta.2

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 (119) hide show
  1. package/.github/workflows/release.yml +44 -0
  2. package/.goreleaser.yml +44 -0
  3. package/AGENTS.md +60 -0
  4. package/CLAUDE.md +192 -0
  5. package/README.md +2 -0
  6. package/docs/architecture/awiki-command-v2.md +955 -0
  7. package/docs/architecture/awiki-skill-architecture.md +475 -0
  8. package/docs/architecture/awiki-v2-architecture.md +1063 -0
  9. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/cli-init.md +1008 -0
  10. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/output-format.md +407 -0
  11. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/overall-init.md +741 -0
  12. package/docs/harness/review-spec.md +474 -0
  13. package/docs/installation.md +372 -0
  14. package/docs/plan/awiki-v2-implementation-plan.md +903 -0
  15. package/docs/plan/phase-0/adr-index.md +56 -0
  16. package/docs/plan/phase-0/audit-findings.md +251 -0
  17. package/docs/plan/phase-0/capability-mapping.md +108 -0
  18. package/docs/plan/phase-0/implementation-constraints.md +363 -0
  19. package/docs/publish.md +169 -0
  20. package/go.mod +29 -0
  21. package/go.sum +73 -0
  22. package/internal/anpsdk/registry.go +63 -0
  23. package/internal/authsdk/session.go +351 -0
  24. package/internal/buildinfo/buildinfo.go +34 -0
  25. package/internal/cli/app.go +136 -0
  26. package/internal/cli/app_test.go +88 -0
  27. package/internal/cli/debug.go +104 -0
  28. package/internal/cli/group.go +263 -0
  29. package/internal/cli/id.go +473 -0
  30. package/internal/cli/init.go +134 -0
  31. package/internal/cli/msg.go +228 -0
  32. package/internal/cli/page.go +267 -0
  33. package/internal/cli/root.go +499 -0
  34. package/internal/cli/runtime.go +232 -0
  35. package/internal/cli/upgrade.go +60 -0
  36. package/internal/cmdmeta/catalog.go +203 -0
  37. package/internal/cmdmeta/catalog_test.go +21 -0
  38. package/internal/config/config.go +399 -0
  39. package/internal/config/config_test.go +104 -0
  40. package/internal/config/write.go +37 -0
  41. package/internal/content/service.go +314 -0
  42. package/internal/content/service_test.go +165 -0
  43. package/internal/content/types.go +44 -0
  44. package/internal/docs/topics.go +110 -0
  45. package/internal/doctor/doctor.go +306 -0
  46. package/internal/identity/client.go +267 -0
  47. package/internal/identity/did.go +85 -0
  48. package/internal/identity/did_test.go +50 -0
  49. package/internal/identity/layout.go +206 -0
  50. package/internal/identity/legacy.go +378 -0
  51. package/internal/identity/public.go +70 -0
  52. package/internal/identity/public_test.go +73 -0
  53. package/internal/identity/readiness.go +74 -0
  54. package/internal/identity/service.go +826 -0
  55. package/internal/identity/store.go +385 -0
  56. package/internal/identity/store_test.go +180 -0
  57. package/internal/identity/types.go +204 -0
  58. package/internal/message/auth.go +167 -0
  59. package/internal/message/group_service.go +838 -0
  60. package/internal/message/group_wire.go +350 -0
  61. package/internal/message/group_wire_test.go +67 -0
  62. package/internal/message/helpers.go +61 -0
  63. package/internal/message/http_client.go +334 -0
  64. package/internal/message/proof.go +156 -0
  65. package/internal/message/proof_test.go +61 -0
  66. package/internal/message/service.go +696 -0
  67. package/internal/message/service_test.go +97 -0
  68. package/internal/message/types.go +155 -0
  69. package/internal/message/wire.go +100 -0
  70. package/internal/message/wire_test.go +49 -0
  71. package/internal/message/ws_proxy_client.go +151 -0
  72. package/internal/output/output.go +350 -0
  73. package/internal/output/output_test.go +48 -0
  74. package/internal/runtime/config.go +117 -0
  75. package/internal/runtime/config_test.go +46 -0
  76. package/internal/runtime/listener/files.go +65 -0
  77. package/internal/runtime/listener/manager.go +142 -0
  78. package/internal/runtime/listener/server.go +983 -0
  79. package/internal/runtime/listener/server_test.go +319 -0
  80. package/internal/runtime/listener/sysproc_unix.go +17 -0
  81. package/internal/runtime/listener/sysproc_windows.go +13 -0
  82. package/internal/runtime/listener/types.go +21 -0
  83. package/internal/runtime/listener/wsclient.go +299 -0
  84. package/internal/runtime/listener/wsclient_test.go +41 -0
  85. package/internal/store/dao.go +632 -0
  86. package/internal/store/dao_test.go +87 -0
  87. package/internal/store/helpers.go +197 -0
  88. package/internal/store/import.go +499 -0
  89. package/internal/store/import_test.go +103 -0
  90. package/internal/store/open.go +71 -0
  91. package/internal/store/query.go +151 -0
  92. package/internal/store/schema.go +277 -0
  93. package/internal/store/schema_test.go +56 -0
  94. package/internal/store/types.go +177 -0
  95. package/internal/update/update.go +368 -0
  96. package/package.json +17 -0
  97. package/scripts/install.js +171 -0
  98. package/scripts/release/release-prerelease.sh +86 -0
  99. package/scripts/release/tag-release.sh +66 -0
  100. package/scripts/release/withdraw-release.sh +78 -0
  101. package/scripts/run.js +69 -0
  102. package/skills/README.md +32 -0
  103. package/skills/awiki-bundle/SKILL.md +76 -0
  104. package/skills/awiki-debug/SKILL.md +80 -0
  105. package/skills/awiki-group/SKILL.md +111 -0
  106. package/skills/awiki-id/SKILL.md +123 -0
  107. package/skills/awiki-msg/SKILL.md +131 -0
  108. package/skills/awiki-page/SKILL.md +93 -0
  109. package/skills/awiki-people/SKILL.md +66 -0
  110. package/skills/awiki-runtime/SKILL.md +137 -0
  111. package/skills/awiki-shared/SKILL.md +124 -0
  112. package/skills/awiki-workflow-discovery/SKILL.md +93 -0
  113. package/skills/awiki-workflow-onboarding/SKILL.md +119 -0
  114. package/skills/manifests/skills.yaml +260 -0
  115. package/skills/templates/bundle-skill-template.md +42 -0
  116. package/skills/templates/debug-skill-template.md +44 -0
  117. package/skills/templates/domain-skill-template.md +56 -0
  118. package/skills/templates/shared-skill-template.md +46 -0
  119. package/skills/templates/workflow-skill-template.md +46 -0
@@ -0,0 +1,838 @@
1
+ package message
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "encoding/json"
7
+ "fmt"
8
+ "sort"
9
+ "strconv"
10
+ "strings"
11
+
12
+ "github.com/agentconnect/awiki-cli/internal/identity"
13
+ "github.com/agentconnect/awiki-cli/internal/store"
14
+ )
15
+
16
+ func (s *Service) CreateGroup(ctx context.Context, request GroupCreateRequest) (*CommandResult, error) {
17
+ if strings.TrimSpace(request.Name) == "" {
18
+ return nil, ErrGroupRequired
19
+ }
20
+ record, err := s.requireActiveIdentity(request.IdentityName)
21
+ if err != nil {
22
+ return nil, err
23
+ }
24
+ transport, warnings, err := s.transportFor(record)
25
+ if err != nil {
26
+ return nil, err
27
+ }
28
+ result, err := transport.CreateGroup(ctx, request)
29
+ if err != nil {
30
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
31
+ if httpErr != nil {
32
+ return nil, err
33
+ }
34
+ result, err = httpTransport.CreateGroup(ctx, request)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
39
+ warnings = append(warnings, httpWarnings...)
40
+ }
41
+ groupDID := stringFromAny(result["group_did"])
42
+ warnings = append(warnings, s.syncGroupState(ctx, record, groupDID, true)...)
43
+ snapshot, _ := s.readCachedGroupSnapshot(ctx, record, groupDID)
44
+ members, _ := s.readCachedGroupMembers(ctx, record, groupDID, 100)
45
+ return &CommandResult{
46
+ Data: map[string]any{
47
+ "group": snapshot,
48
+ "members": members,
49
+ "delivery": result,
50
+ "source": sourceWithDefault(result, s.runtimeConfig().Mode),
51
+ },
52
+ Summary: fmt.Sprintf("Created group %s", groupDID),
53
+ Warnings: compactWarnings(warnings),
54
+ }, nil
55
+ }
56
+
57
+ func (s *Service) GetGroup(ctx context.Context, request GroupGetRequest) (*CommandResult, error) {
58
+ if strings.TrimSpace(request.Group) == "" {
59
+ return nil, ErrGroupRequired
60
+ }
61
+ record, err := s.requireActiveIdentity(request.IdentityName)
62
+ if err != nil {
63
+ return nil, err
64
+ }
65
+ transport, warnings, err := s.transportFor(record)
66
+ if err != nil {
67
+ return nil, err
68
+ }
69
+ result, err := transport.GetGroup(ctx, request)
70
+ if err != nil {
71
+ cached, cacheErr := s.readCachedGroupSnapshot(ctx, record, request.Group)
72
+ if cacheErr == nil && len(cached) > 0 {
73
+ return &CommandResult{Data: map[string]any{"group": cached, "source": "local_ws_cache_fallback"}, Summary: "Loaded group snapshot from local cache", Warnings: []string{err.Error()}}, nil
74
+ }
75
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
76
+ if httpErr != nil {
77
+ return nil, err
78
+ }
79
+ result, err = httpTransport.GetGroup(ctx, request)
80
+ if err != nil {
81
+ return nil, err
82
+ }
83
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
84
+ warnings = append(warnings, httpWarnings...)
85
+ }
86
+ warnings = append(warnings, s.persistGroupSnapshot(ctx, record, result)...)
87
+ snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group)
88
+ if len(snapshot) == 0 {
89
+ snapshot = normalizeGroupSnapshot(result)
90
+ }
91
+ return &CommandResult{Data: map[string]any{"group": snapshot, "source": sourceWithDefault(result, s.runtimeConfig().Mode)}, Summary: "Loaded group snapshot", Warnings: compactWarnings(warnings)}, nil
92
+ }
93
+
94
+ func (s *Service) JoinGroup(ctx context.Context, request GroupJoinRequest) (*CommandResult, error) {
95
+ if strings.TrimSpace(request.Group) == "" {
96
+ return nil, ErrGroupRequired
97
+ }
98
+ record, err := s.requireActiveIdentity(request.IdentityName)
99
+ if err != nil {
100
+ return nil, err
101
+ }
102
+ transport, warnings, err := s.transportFor(record)
103
+ if err != nil {
104
+ return nil, err
105
+ }
106
+ result, err := transport.JoinGroup(ctx, request)
107
+ if err != nil {
108
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
109
+ if httpErr != nil {
110
+ return nil, err
111
+ }
112
+ result, err = httpTransport.JoinGroup(ctx, request)
113
+ if err != nil {
114
+ return nil, err
115
+ }
116
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
117
+ warnings = append(warnings, httpWarnings...)
118
+ }
119
+ groupDID := stringFromAny(result["group_did"])
120
+ warnings = append(warnings, s.syncGroupState(ctx, record, groupDID, true)...)
121
+ snapshot, _ := s.readCachedGroupSnapshot(ctx, record, groupDID)
122
+ return &CommandResult{Data: map[string]any{"group": snapshot, "delivery": result, "source": sourceWithDefault(result, s.runtimeConfig().Mode)}, Summary: fmt.Sprintf("Joined group %s", groupDID), Warnings: compactWarnings(warnings)}, nil
123
+ }
124
+
125
+ func (s *Service) AddGroupMember(ctx context.Context, request GroupMemberRequest) (*CommandResult, error) {
126
+ return s.mutateGroupMember(ctx, request, "add")
127
+ }
128
+
129
+ func (s *Service) RemoveGroupMember(ctx context.Context, request GroupMemberRequest) (*CommandResult, error) {
130
+ return s.mutateGroupMember(ctx, request, "remove")
131
+ }
132
+
133
+ func (s *Service) mutateGroupMember(ctx context.Context, request GroupMemberRequest, action string) (*CommandResult, error) {
134
+ if strings.TrimSpace(request.Group) == "" {
135
+ return nil, ErrGroupRequired
136
+ }
137
+ if strings.TrimSpace(request.Member) == "" {
138
+ return nil, ErrMemberRequired
139
+ }
140
+ record, err := s.requireActiveIdentity(request.IdentityName)
141
+ if err != nil {
142
+ return nil, err
143
+ }
144
+ memberDID, memberHandle, err := s.resolveTarget(ctx, request.Member)
145
+ if err != nil {
146
+ return nil, err
147
+ }
148
+ request.Member = memberDID
149
+ transport, warnings, err := s.transportFor(record)
150
+ if err != nil {
151
+ return nil, err
152
+ }
153
+ var result map[string]any
154
+ if action == "add" {
155
+ result, err = transport.AddGroupMember(ctx, request)
156
+ } else {
157
+ result, err = transport.RemoveGroupMember(ctx, request)
158
+ }
159
+ if err != nil {
160
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
161
+ if httpErr != nil {
162
+ return nil, err
163
+ }
164
+ if action == "add" {
165
+ result, err = httpTransport.AddGroupMember(ctx, request)
166
+ } else {
167
+ result, err = httpTransport.RemoveGroupMember(ctx, request)
168
+ }
169
+ if err != nil {
170
+ return nil, err
171
+ }
172
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
173
+ warnings = append(warnings, httpWarnings...)
174
+ }
175
+ warnings = append(warnings, s.syncGroupState(ctx, record, request.Group, true)...)
176
+ snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group)
177
+ members, _ := s.readCachedGroupMembers(ctx, record, request.Group, 100)
178
+ return &CommandResult{Data: map[string]any{"group": snapshot, "members": members, "delivery": result, "member": map[string]any{"did": memberDID, "handle": memberHandle}}, Summary: fmt.Sprintf("Updated group membership via %s", action), Warnings: compactWarnings(warnings)}, nil
179
+ }
180
+
181
+ func (s *Service) LeaveGroup(ctx context.Context, request GroupLeaveRequest) (*CommandResult, error) {
182
+ if strings.TrimSpace(request.Group) == "" {
183
+ return nil, ErrGroupRequired
184
+ }
185
+ record, err := s.requireActiveIdentity(request.IdentityName)
186
+ if err != nil {
187
+ return nil, err
188
+ }
189
+ transport, warnings, err := s.transportFor(record)
190
+ if err != nil {
191
+ return nil, err
192
+ }
193
+ result, err := transport.LeaveGroup(ctx, request)
194
+ if err != nil {
195
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
196
+ if httpErr != nil {
197
+ return nil, err
198
+ }
199
+ result, err = httpTransport.LeaveGroup(ctx, request)
200
+ if err != nil {
201
+ return nil, err
202
+ }
203
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
204
+ warnings = append(warnings, httpWarnings...)
205
+ }
206
+ warnings = append(warnings, s.markCachedGroupLeft(ctx, record, request.Group)...)
207
+ return &CommandResult{Data: map[string]any{"delivery": result, "group": request.Group}, Summary: fmt.Sprintf("Left group %s", request.Group), Warnings: compactWarnings(warnings)}, nil
208
+ }
209
+
210
+ func (s *Service) UpdateGroup(ctx context.Context, request GroupUpdateRequest) (*CommandResult, error) {
211
+ if strings.TrimSpace(request.Group) == "" {
212
+ return nil, ErrGroupRequired
213
+ }
214
+ record, err := s.requireActiveIdentity(request.IdentityName)
215
+ if err != nil {
216
+ return nil, err
217
+ }
218
+ profilePatch := buildGroupProfilePatch(request.Name, request.Description, request.Discoverability, request.Slug, request.Goal, request.Rules, request.MessagePrompt, request.DocURL)
219
+ policyPatch := buildGroupPolicyPatch(request.AdmissionMode, request.AttachmentsAllowed, request.MaxMembers, request.MemberMaxMessages, request.MemberMaxTotalChars)
220
+ if len(profilePatch) == 0 && len(policyPatch) == 0 {
221
+ return nil, fmt.Errorf("group update requires at least one mutable field")
222
+ }
223
+ transport, warnings, err := s.transportFor(record)
224
+ if err != nil {
225
+ return nil, err
226
+ }
227
+ responses := make([]map[string]any, 0, 2)
228
+ if len(profilePatch) > 0 {
229
+ result, callErr := transport.UpdateGroupProfile(ctx, GroupGetRequest{Group: request.Group}, profilePatch)
230
+ if callErr != nil {
231
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
232
+ if httpErr != nil {
233
+ return nil, callErr
234
+ }
235
+ result, callErr = httpTransport.UpdateGroupProfile(ctx, GroupGetRequest{Group: request.Group}, profilePatch)
236
+ if callErr != nil {
237
+ return nil, callErr
238
+ }
239
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
240
+ warnings = append(warnings, httpWarnings...)
241
+ }
242
+ responses = append(responses, result)
243
+ }
244
+ if len(policyPatch) > 0 {
245
+ result, callErr := transport.UpdateGroupPolicy(ctx, GroupGetRequest{Group: request.Group}, policyPatch)
246
+ if callErr != nil {
247
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
248
+ if httpErr != nil {
249
+ return nil, callErr
250
+ }
251
+ result, callErr = httpTransport.UpdateGroupPolicy(ctx, GroupGetRequest{Group: request.Group}, policyPatch)
252
+ if callErr != nil {
253
+ return nil, callErr
254
+ }
255
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
256
+ warnings = append(warnings, httpWarnings...)
257
+ }
258
+ responses = append(responses, result)
259
+ }
260
+ warnings = append(warnings, s.syncGroupState(ctx, record, request.Group, false)...)
261
+ snapshot, _ := s.readCachedGroupSnapshot(ctx, record, request.Group)
262
+ return &CommandResult{Data: map[string]any{"group": snapshot, "delivery": responses}, Summary: fmt.Sprintf("Updated group %s", request.Group), Warnings: compactWarnings(warnings)}, nil
263
+ }
264
+
265
+ func (s *Service) GroupMembers(ctx context.Context, request GroupMembersRequest) (*CommandResult, error) {
266
+ if strings.TrimSpace(request.Group) == "" {
267
+ return nil, ErrGroupRequired
268
+ }
269
+ record, err := s.requireActiveIdentity(request.IdentityName)
270
+ if err != nil {
271
+ return nil, err
272
+ }
273
+ transport, warnings, err := s.transportFor(record)
274
+ if err != nil {
275
+ return nil, err
276
+ }
277
+ result, err := transport.ListGroupMembers(ctx, request)
278
+ if err != nil {
279
+ cached, cacheErr := s.readCachedGroupMembers(ctx, record, request.Group, request.Limit)
280
+ if cacheErr == nil && len(cached) > 0 {
281
+ return &CommandResult{Data: map[string]any{"members": cached, "total": len(cached), "group": request.Group, "source": "local_ws_cache_fallback"}, Summary: "Loaded group members from local cache", Warnings: []string{err.Error()}}, nil
282
+ }
283
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
284
+ if httpErr != nil {
285
+ return nil, err
286
+ }
287
+ result, err = httpTransport.ListGroupMembers(ctx, request)
288
+ if err != nil {
289
+ return nil, err
290
+ }
291
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
292
+ warnings = append(warnings, httpWarnings...)
293
+ }
294
+ warnings = append(warnings, s.persistGroupMembers(ctx, record, request.Group, result)...)
295
+ members, _ := s.readCachedGroupMembers(ctx, record, request.Group, request.Limit)
296
+ if len(members) == 0 {
297
+ members = groupMembersFromResult(result["members"])
298
+ }
299
+ total := intValueFromAny(result["total"], len(members))
300
+ return &CommandResult{Data: map[string]any{"group": request.Group, "members": members, "total": total, "source": sourceWithDefault(result, s.runtimeConfig().Mode)}, Summary: fmt.Sprintf("Loaded %d group members", total), Warnings: compactWarnings(warnings)}, nil
301
+ }
302
+
303
+ func (s *Service) GroupMessages(ctx context.Context, request GroupMessagesRequest) (*CommandResult, error) {
304
+ if strings.TrimSpace(request.Group) == "" {
305
+ return nil, ErrGroupRequired
306
+ }
307
+ record, err := s.requireActiveIdentity(request.IdentityName)
308
+ if err != nil {
309
+ return nil, err
310
+ }
311
+ transport, warnings, err := s.transportFor(record)
312
+ if err != nil {
313
+ return nil, err
314
+ }
315
+ result, err := transport.ListGroupMessages(ctx, request)
316
+ if err != nil {
317
+ cached, cacheErr := s.readCachedGroupMessages(ctx, record, request.Group, request.Limit, request.Cursor)
318
+ if cacheErr == nil && len(cached) > 0 {
319
+ return &CommandResult{Data: map[string]any{"group": request.Group, "messages": cached, "total": len(cached), "source": "local_ws_cache_fallback"}, Summary: "Loaded group messages from local cache", Warnings: []string{err.Error()}}, nil
320
+ }
321
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
322
+ if httpErr != nil {
323
+ return nil, err
324
+ }
325
+ result, err = httpTransport.ListGroupMessages(ctx, request)
326
+ if err != nil {
327
+ return nil, err
328
+ }
329
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
330
+ warnings = append(warnings, httpWarnings...)
331
+ }
332
+ warnings = append(warnings, s.persistGroupMessages(ctx, record, request.Group, result)...)
333
+ messages, _ := s.readCachedGroupMessages(ctx, record, request.Group, request.Limit, request.Cursor)
334
+ if len(messages) == 0 {
335
+ messages = messagesFromResult(result["messages"])
336
+ }
337
+ total := intValueFromAny(result["total"], len(messages))
338
+ return &CommandResult{Data: map[string]any{"group": request.Group, "messages": messages, "total": total, "has_more": boolFromAny(result["has_more"]), "next_since_seq": result["next_since_seq"], "source": sourceWithDefault(result, s.runtimeConfig().Mode)}, Summary: fmt.Sprintf("Loaded %d group messages", total), Warnings: compactWarnings(warnings)}, nil
339
+ }
340
+
341
+ func (s *Service) sendGroup(ctx context.Context, request SendRequest) (*CommandResult, error) {
342
+ if strings.TrimSpace(request.Group) == "" {
343
+ return nil, ErrGroupRequired
344
+ }
345
+ if strings.TrimSpace(request.Text) == "" {
346
+ return nil, ErrTextRequired
347
+ }
348
+ if request.SecureMode == "on" {
349
+ return nil, ErrSecureNotSupported
350
+ }
351
+ record, err := s.requireActiveIdentity(request.IdentityName)
352
+ if err != nil {
353
+ return nil, err
354
+ }
355
+ transport, warnings, err := s.transportFor(record)
356
+ if err != nil {
357
+ return nil, err
358
+ }
359
+ result, err := transport.SendGroup(ctx, request)
360
+ if err != nil {
361
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
362
+ if httpErr != nil {
363
+ return nil, err
364
+ }
365
+ result, err = httpTransport.SendGroup(ctx, request)
366
+ if err != nil {
367
+ return nil, err
368
+ }
369
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
370
+ warnings = append(warnings, httpWarnings...)
371
+ }
372
+ return s.persistGroupSendResult(ctx, record, request, result, warnings)
373
+ }
374
+
375
+ func (s *Service) syncGroupState(ctx context.Context, record *identity.StoredIdentity, groupDID string, includeMembers bool) []string {
376
+ if strings.TrimSpace(groupDID) == "" {
377
+ return nil
378
+ }
379
+ httpTransport, _, err := s.httpTransport(record)
380
+ if err != nil {
381
+ return []string{fmt.Sprintf("Failed to prepare group sync transport: %v", err)}
382
+ }
383
+ warnings := make([]string, 0)
384
+ groupResult, err := httpTransport.GetGroup(ctx, GroupGetRequest{Group: groupDID})
385
+ if err != nil {
386
+ return []string{fmt.Sprintf("Failed to refresh group snapshot: %v", err)}
387
+ }
388
+ warnings = append(warnings, s.persistGroupSnapshot(ctx, record, groupResult)...)
389
+ if includeMembers {
390
+ memberResult, memberErr := httpTransport.ListGroupMembers(ctx, GroupMembersRequest{Group: groupDID, Limit: 100})
391
+ if memberErr != nil {
392
+ warnings = append(warnings, fmt.Sprintf("Failed to refresh group members: %v", memberErr))
393
+ } else {
394
+ warnings = append(warnings, s.persistGroupMembers(ctx, record, groupDID, memberResult)...)
395
+ }
396
+ }
397
+ return compactWarnings(warnings)
398
+ }
399
+
400
+ func (s *Service) persistGroupSendResult(ctx context.Context, record *identity.StoredIdentity, request SendRequest, result *groupSendResult, warnings []string) (*CommandResult, error) {
401
+ db, err := store.Open(s.resolved.Paths)
402
+ if err != nil {
403
+ return nil, err
404
+ }
405
+ defer db.Close()
406
+ if err := store.EnsureSchema(ctx, db); err != nil {
407
+ return nil, err
408
+ }
409
+ msgID := result.MessageID
410
+ if strings.TrimSpace(result.GroupDID) != "" && strings.TrimSpace(result.GroupEventSeq) != "" {
411
+ msgID = fmt.Sprintf("%s:%s", result.GroupDID, result.GroupEventSeq)
412
+ } else if msgID == "" {
413
+ msgID = "msg-" + generateOperationID()
414
+ }
415
+ groupKey := groupStorageKey(request.Group)
416
+ if err := store.StoreMessage(ctx, db, store.MessageRecord{
417
+ MsgID: msgID,
418
+ OwnerDID: record.DID,
419
+ ThreadID: store.MakeThreadID(record.DID, "", groupKey),
420
+ Direction: 1,
421
+ SenderDID: record.DID,
422
+ GroupID: groupKey,
423
+ GroupDID: request.Group,
424
+ ContentType: contentTypeForMessageType(request.MessageType),
425
+ Content: request.Text,
426
+ SentAt: result.AcceptedAt,
427
+ IsRead: true,
428
+ Metadata: metadataString(map[string]any{"group_event_seq": result.GroupEventSeq, "group_state_version": result.GroupStateVersion, "operation_id": result.OperationID}),
429
+ CredentialName: record.IdentityName,
430
+ }); err != nil {
431
+ warnings = append(warnings, fmt.Sprintf("Failed to persist local group message: %v", err))
432
+ }
433
+ warnings = append(warnings, s.touchCachedGroup(ctx, record, request.Group, result.AcceptedAt, result.GroupEventSeq, result.GroupStateVersion)...)
434
+ return &CommandResult{Data: map[string]any{"action": "send_message", "target": map[string]any{"kind": "group", "did": request.Group}, "message": map[string]any{"id": msgID, "type": request.MessageType, "secure": false, "sent_at": result.AcceptedAt}, "delivery": result}, Summary: fmt.Sprintf("Sent a group %s message", request.MessageType), Warnings: compactWarnings(warnings)}, nil
435
+ }
436
+
437
+ func (s *Service) persistGroupSnapshot(ctx context.Context, record *identity.StoredIdentity, raw map[string]any) []string {
438
+ snapshot := normalizeGroupSnapshot(raw)
439
+ if len(snapshot) == 0 {
440
+ return nil
441
+ }
442
+ db, err := store.Open(s.resolved.Paths)
443
+ if err != nil {
444
+ return []string{fmt.Sprintf("Failed to open local store for group snapshot: %v", err)}
445
+ }
446
+ defer db.Close()
447
+ if err := store.EnsureSchema(ctx, db); err != nil {
448
+ return []string{fmt.Sprintf("Failed to ensure local schema for group snapshot: %v", err)}
449
+ }
450
+ groupDID := stringFromAny(snapshot["group_did"])
451
+ if groupDID == "" {
452
+ return nil
453
+ }
454
+ memberCount := int64PtrFromAny(snapshot["member_count"])
455
+ lastSyncedSeq := int64PtrFromAny(snapshot["group_event_seq"])
456
+ recordToStore := store.GroupRecord{
457
+ OwnerDID: record.DID,
458
+ GroupID: groupStorageKey(groupDID),
459
+ GroupDID: groupDID,
460
+ Name: stringFromAny(snapshot["name"]),
461
+ Slug: stringFromAny(snapshot["slug"]),
462
+ Description: stringFromAny(snapshot["description"]),
463
+ Goal: stringFromAny(snapshot["goal"]),
464
+ Rules: stringFromAny(snapshot["rules"]),
465
+ MessagePrompt: stringFromAny(snapshot["message_prompt"]),
466
+ DocURL: stringFromAny(snapshot["doc_url"]),
467
+ GroupOwnerDID: stringFromAny(snapshot["owner_did"]),
468
+ MyRole: stringFromAny(snapshot["member_role"]),
469
+ MembershipStatus: stringFromAny(snapshot["member_status"]),
470
+ JoinEnabled: boolPtrFromAny(snapshot["join_enabled"]),
471
+ MemberCount: memberCount,
472
+ LastSyncedSeq: lastSyncedSeq,
473
+ RemoteCreatedAt: stringFromAny(snapshot["created_at"]),
474
+ RemoteUpdatedAt: stringFromAny(snapshot["updated_at"]),
475
+ Metadata: metadataString(snapshot),
476
+ CredentialName: record.IdentityName,
477
+ }
478
+ if err := store.UpsertGroup(ctx, db, recordToStore); err != nil {
479
+ return []string{fmt.Sprintf("Failed to persist group snapshot: %v", err)}
480
+ }
481
+ return nil
482
+ }
483
+
484
+ func (s *Service) persistGroupMembers(ctx context.Context, record *identity.StoredIdentity, groupDID string, raw map[string]any) []string {
485
+ members := groupMembersFromResult(raw["members"])
486
+ if len(members) == 0 {
487
+ return nil
488
+ }
489
+ db, err := store.Open(s.resolved.Paths)
490
+ if err != nil {
491
+ return []string{fmt.Sprintf("Failed to open local store for group members: %v", err)}
492
+ }
493
+ defer db.Close()
494
+ if err := store.EnsureSchema(ctx, db); err != nil {
495
+ return []string{fmt.Sprintf("Failed to ensure local schema for group members: %v", err)}
496
+ }
497
+ records := make([]store.GroupMemberRecord, 0, len(members))
498
+ for _, member := range members {
499
+ memberDID := stringFromAny(member["agent_did"])
500
+ if memberDID == "" {
501
+ continue
502
+ }
503
+ records = append(records, store.GroupMemberRecord{
504
+ OwnerDID: record.DID,
505
+ GroupID: groupStorageKey(groupDID),
506
+ UserID: memberDID,
507
+ MemberDID: memberDID,
508
+ Role: stringFromAny(member["role"]),
509
+ Status: stringFromAny(member["status"]),
510
+ JoinedAt: stringFromAny(member["joined_at"]),
511
+ Metadata: metadataString(member),
512
+ CredentialName: record.IdentityName,
513
+ })
514
+ }
515
+ if err := store.ReplaceGroupMembers(ctx, db, record.DID, groupStorageKey(groupDID), records, record.IdentityName); err != nil {
516
+ return []string{fmt.Sprintf("Failed to persist group members: %v", err)}
517
+ }
518
+ return nil
519
+ }
520
+
521
+ func (s *Service) persistGroupMessages(ctx context.Context, record *identity.StoredIdentity, groupDID string, raw map[string]any) []string {
522
+ messages := messagesFromResult(raw["messages"])
523
+ if len(messages) == 0 {
524
+ return nil
525
+ }
526
+ db, err := store.Open(s.resolved.Paths)
527
+ if err != nil {
528
+ return []string{fmt.Sprintf("Failed to open local store for group messages: %v", err)}
529
+ }
530
+ defer db.Close()
531
+ if err := store.EnsureSchema(ctx, db); err != nil {
532
+ return []string{fmt.Sprintf("Failed to ensure local schema for group messages: %v", err)}
533
+ }
534
+ batch := make([]store.MessageRecord, 0, len(messages))
535
+ for _, item := range messages {
536
+ msgID := stringFromAny(item["id"])
537
+ if msgID == "" {
538
+ msgID = stringFromAny(item["message_id"])
539
+ }
540
+ if msgID == "" {
541
+ continue
542
+ }
543
+ direction := 0
544
+ if stringFromAny(item["sender_did"]) == record.DID {
545
+ direction = 1
546
+ }
547
+ contentType := stringFromAny(item["content_type"])
548
+ contentValue := item["content"]
549
+ content := stringFromAny(contentValue)
550
+ if content == "" {
551
+ content = metadataString(contentValue)
552
+ }
553
+ batch = append(batch, store.MessageRecord{
554
+ MsgID: msgID,
555
+ OwnerDID: record.DID,
556
+ ThreadID: store.MakeThreadID(record.DID, "", groupStorageKey(groupDID)),
557
+ Direction: direction,
558
+ SenderDID: stringFromAny(item["sender_did"]),
559
+ GroupID: groupStorageKey(groupDID),
560
+ GroupDID: groupDID,
561
+ ContentType: defaultString(contentType, inferGroupMessageContentType(item)),
562
+ Content: content,
563
+ ServerSeq: int64PtrFromAny(item["server_seq"]),
564
+ SentAt: defaultString(stringFromAny(item["sent_at"]), stringFromAny(item["created_at"])),
565
+ IsRead: boolFromAny(item["is_read"]),
566
+ Metadata: metadataString(item),
567
+ CredentialName: record.IdentityName,
568
+ })
569
+ }
570
+ if err := store.StoreMessagesBatch(ctx, db, batch); err != nil {
571
+ return []string{fmt.Sprintf("Failed to persist group messages: %v", err)}
572
+ }
573
+ if len(messages) > 0 {
574
+ latest := messages[0]
575
+ _ = store.UpsertGroup(ctx, db, store.GroupRecord{
576
+ OwnerDID: record.DID,
577
+ GroupID: groupStorageKey(groupDID),
578
+ GroupDID: groupDID,
579
+ LastSyncedSeq: int64PtrFromAny(raw["next_since_seq"]),
580
+ LastMessageAt: defaultString(stringFromAny(latest["sent_at"]), stringFromAny(latest["created_at"])),
581
+ CredentialName: record.IdentityName,
582
+ Metadata: metadataString(map[string]any{"source": "group.list_messages"}),
583
+ })
584
+ }
585
+ return nil
586
+ }
587
+
588
+ func (s *Service) touchCachedGroup(ctx context.Context, record *identity.StoredIdentity, groupDID string, sentAt string, groupEventSeq string, groupStateVersion string) []string {
589
+ db, err := store.Open(s.resolved.Paths)
590
+ if err != nil {
591
+ return []string{fmt.Sprintf("Failed to open local store for group cache update: %v", err)}
592
+ }
593
+ defer db.Close()
594
+ if err := store.EnsureSchema(ctx, db); err != nil {
595
+ return []string{fmt.Sprintf("Failed to ensure local schema for group cache update: %v", err)}
596
+ }
597
+ if err := store.UpsertGroup(ctx, db, store.GroupRecord{OwnerDID: record.DID, GroupID: groupStorageKey(groupDID), GroupDID: groupDID, LastMessageAt: sentAt, LastSyncedSeq: parseInt64Ptr(groupEventSeq), CredentialName: record.IdentityName, Metadata: metadataString(map[string]any{"group_state_version": groupStateVersion})}); err != nil {
598
+ return []string{fmt.Sprintf("Failed to update group cache: %v", err)}
599
+ }
600
+ return nil
601
+ }
602
+
603
+ func (s *Service) markCachedGroupLeft(ctx context.Context, record *identity.StoredIdentity, groupDID string) []string {
604
+ db, err := store.Open(s.resolved.Paths)
605
+ if err != nil {
606
+ return []string{fmt.Sprintf("Failed to open local store for leave projection: %v", err)}
607
+ }
608
+ defer db.Close()
609
+ if err := store.EnsureSchema(ctx, db); err != nil {
610
+ return []string{fmt.Sprintf("Failed to ensure local schema for leave projection: %v", err)}
611
+ }
612
+ if err := store.UpsertGroup(ctx, db, store.GroupRecord{OwnerDID: record.DID, GroupID: groupStorageKey(groupDID), GroupDID: groupDID, MembershipStatus: "left", CredentialName: record.IdentityName}); err != nil {
613
+ return []string{fmt.Sprintf("Failed to update local group leave status: %v", err)}
614
+ }
615
+ return nil
616
+ }
617
+
618
+ func (s *Service) readCachedGroupSnapshot(ctx context.Context, record *identity.StoredIdentity, groupDID string) (map[string]any, error) {
619
+ db, err := store.Open(s.resolved.Paths)
620
+ if err != nil {
621
+ return nil, err
622
+ }
623
+ defer db.Close()
624
+ if err := store.EnsureSchema(ctx, db); err != nil {
625
+ return nil, err
626
+ }
627
+ row, err := store.GetGroupSnapshot(ctx, db, record.DID, groupStorageKey(groupDID))
628
+ if err != nil {
629
+ if err == sql.ErrNoRows {
630
+ return nil, nil
631
+ }
632
+ return nil, err
633
+ }
634
+ return row, nil
635
+ }
636
+
637
+ func (s *Service) readCachedGroupMembers(ctx context.Context, record *identity.StoredIdentity, groupDID string, limit int) ([]map[string]any, error) {
638
+ db, err := store.Open(s.resolved.Paths)
639
+ if err != nil {
640
+ return nil, err
641
+ }
642
+ defer db.Close()
643
+ if err := store.EnsureSchema(ctx, db); err != nil {
644
+ return nil, err
645
+ }
646
+ return store.ListCachedGroupMembers(ctx, db, record.DID, groupStorageKey(groupDID), limit)
647
+ }
648
+
649
+ func (s *Service) readCachedGroupMessages(ctx context.Context, record *identity.StoredIdentity, groupDID string, limit int, cursor string) ([]map[string]any, error) {
650
+ db, err := store.Open(s.resolved.Paths)
651
+ if err != nil {
652
+ return nil, err
653
+ }
654
+ defer db.Close()
655
+ if err := store.EnsureSchema(ctx, db); err != nil {
656
+ return nil, err
657
+ }
658
+ return store.ListGroupMessages(ctx, db, record.DID, groupStorageKey(groupDID), limit, parseInt64Ptr(cursor))
659
+ }
660
+
661
+ func normalizeGroupSnapshot(raw map[string]any) map[string]any {
662
+ if raw == nil {
663
+ return nil
664
+ }
665
+ if snapshot, ok := raw["group_snapshot"].(map[string]any); ok {
666
+ return snapshot
667
+ }
668
+ if groupDID := stringFromAny(raw["group_did"]); groupDID != "" {
669
+ name := ""
670
+ if profile, ok := raw["group_profile"].(map[string]any); ok {
671
+ name = stringFromAny(profile["display_name"])
672
+ return map[string]any{
673
+ "group_did": groupDID,
674
+ "group_state_version": raw["group_state_version"],
675
+ "name": name,
676
+ "description": profile["description"],
677
+ "discoverability": profile["discoverability"],
678
+ "member_count": raw["member_count"],
679
+ "group_profile": profile,
680
+ "group_policy": raw["group_policy"],
681
+ }
682
+ }
683
+ }
684
+ return nil
685
+ }
686
+
687
+ func groupMembersFromResult(value any) []map[string]any {
688
+ return messagesFromResult(value)
689
+ }
690
+
691
+ func inferGroupMessageContentType(message map[string]any) string {
692
+ systemEvent, _ := message["system_event"].(map[string]any)
693
+ subjectMethod := stringFromAny(systemEvent["subject_method"])
694
+ switch subjectMethod {
695
+ case "group.join", "group.add":
696
+ return "group_system_member_joined"
697
+ case "group.leave":
698
+ return "group_system_member_left"
699
+ case "group.remove":
700
+ return "group_system_member_kicked"
701
+ default:
702
+ if systemEvent != nil {
703
+ return "application/json"
704
+ }
705
+ return "text/plain"
706
+ }
707
+ }
708
+
709
+ func groupStorageKey(groupDID string) string {
710
+ return strings.TrimSpace(groupDID)
711
+ }
712
+
713
+ func parseInt64Ptr(value string) *int64 {
714
+ value = strings.TrimSpace(value)
715
+ if value == "" {
716
+ return nil
717
+ }
718
+ parsed, err := strconv.ParseInt(value, 10, 64)
719
+ if err != nil {
720
+ return nil
721
+ }
722
+ return &parsed
723
+ }
724
+
725
+ func boolPtrFromAny(value any) *bool {
726
+ switch typed := value.(type) {
727
+ case bool:
728
+ value := typed
729
+ return &value
730
+ case int:
731
+ value := typed != 0
732
+ return &value
733
+ case int64:
734
+ value := typed != 0
735
+ return &value
736
+ case float64:
737
+ value := typed != 0
738
+ return &value
739
+ case string:
740
+ if typed == "" {
741
+ return nil
742
+ }
743
+ value := strings.EqualFold(typed, "true") || typed == "1"
744
+ return &value
745
+ default:
746
+ return nil
747
+ }
748
+ }
749
+
750
+ func compactWarnings(warnings []string) []string {
751
+ if len(warnings) == 0 {
752
+ return nil
753
+ }
754
+ seen := make(map[string]struct{}, len(warnings))
755
+ result := make([]string, 0, len(warnings))
756
+ for _, warning := range warnings {
757
+ warning = strings.TrimSpace(warning)
758
+ if warning == "" {
759
+ continue
760
+ }
761
+ if _, ok := seen[warning]; ok {
762
+ continue
763
+ }
764
+ seen[warning] = struct{}{}
765
+ result = append(result, warning)
766
+ }
767
+ if len(result) == 0 {
768
+ return nil
769
+ }
770
+ return result
771
+ }
772
+
773
+ func encodeAnyString(value any) string {
774
+ if value == nil {
775
+ return ""
776
+ }
777
+ if text, ok := value.(string); ok {
778
+ return text
779
+ }
780
+ raw, err := json.Marshal(value)
781
+ if err != nil {
782
+ return ""
783
+ }
784
+ return string(raw)
785
+ }
786
+
787
+ func readGroupInboxFromCache(ctx context.Context, resolvedPathOpener func() (*sql.DB, error), ownerDID string, groupDID string, limit int, unreadOnly bool) ([]map[string]any, error) {
788
+ db, err := resolvedPathOpener()
789
+ if err != nil {
790
+ return nil, err
791
+ }
792
+ defer db.Close()
793
+ if err := store.EnsureSchema(ctx, db); err != nil {
794
+ return nil, err
795
+ }
796
+ return store.ListGroupInboxMessages(ctx, db, ownerDID, limit, groupStorageKey(groupDID), unreadOnly)
797
+ }
798
+
799
+ func (s *Service) readGroupInboxFromCache(ctx context.Context, record *identity.StoredIdentity, groupDID string, limit int, unreadOnly bool) ([]map[string]any, error) {
800
+ return readGroupInboxFromCache(ctx, func() (*sql.DB, error) { return store.Open(s.resolved.Paths) }, record.DID, groupDID, limit, unreadOnly)
801
+ }
802
+
803
+ func readAllLocalGroupInbox(ctx context.Context, resolvedPathOpener func() (*sql.DB, error), ownerDID string, limit int, unreadOnly bool) ([]map[string]any, error) {
804
+ db, err := resolvedPathOpener()
805
+ if err != nil {
806
+ return nil, err
807
+ }
808
+ defer db.Close()
809
+ if err := store.EnsureSchema(ctx, db); err != nil {
810
+ return nil, err
811
+ }
812
+ return store.ListGroupInboxMessages(ctx, db, ownerDID, limit, "", unreadOnly)
813
+ }
814
+
815
+ func (s *Service) readAllLocalGroupInbox(ctx context.Context, record *identity.StoredIdentity, limit int, unreadOnly bool) ([]map[string]any, error) {
816
+ return readAllLocalGroupInbox(ctx, func() (*sql.DB, error) { return store.Open(s.resolved.Paths) }, record.DID, limit, unreadOnly)
817
+ }
818
+
819
+ func mergeInboxMessages(limit int, left []map[string]any, right []map[string]any) []map[string]any {
820
+ all := make([]map[string]any, 0, len(left)+len(right))
821
+ all = append(all, left...)
822
+ all = append(all, right...)
823
+ if len(all) <= 1 {
824
+ if limit > 0 && len(all) > limit {
825
+ return all[:limit]
826
+ }
827
+ return all
828
+ }
829
+ sort.SliceStable(all, func(i, j int) bool {
830
+ leftTS := defaultString(stringFromAny(all[i]["sent_at"]), stringFromAny(all[i]["stored_at"]))
831
+ rightTS := defaultString(stringFromAny(all[j]["sent_at"]), stringFromAny(all[j]["stored_at"]))
832
+ return leftTS > rightTS
833
+ })
834
+ if limit > 0 && len(all) > limit {
835
+ return all[:limit]
836
+ }
837
+ return all
838
+ }