deepspace 0.26.0 → 0.26.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 (52) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/{chunk-LPXWXF6R.js → chunk-S2OGAK6U.js} +2 -2
  3. package/dist/{chunk-LPXWXF6R.js.map → chunk-S2OGAK6U.js.map} +1 -1
  4. package/dist/cli.js +82 -45
  5. package/dist/cli.js.map +1 -1
  6. package/dist/documentation-client-core.js +1 -1
  7. package/dist/documentation-default-renderer.cjs +1 -1
  8. package/dist/documentation-react.js +1 -1
  9. package/dist/documentation-runtime.js +1 -1
  10. package/dist/documentation-server-core.js +1 -1
  11. package/dist/documentation.js +1 -1
  12. package/dist/documentation.js.map +1 -1
  13. package/dist/index.d.ts +26 -434
  14. package/dist/index.js +99 -864
  15. package/dist/index.js.map +1 -1
  16. package/dist/schema.d.ts +4 -41
  17. package/dist/schema.js +2 -242
  18. package/dist/schema.js.map +1 -1
  19. package/dist/server.d.ts +6 -162
  20. package/dist/server.js +13 -281
  21. package/dist/server.js.map +1 -1
  22. package/dist/worker.d.ts +4 -160
  23. package/dist/worker.js +15 -281
  24. package/dist/worker.js.map +1 -1
  25. package/features/messaging/feature.json +3 -43
  26. package/features/messaging/src/ChatPage.tsx +12 -16
  27. package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
  28. package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
  29. package/features/messaging/src/components/chat/MessageList.tsx +57 -40
  30. package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
  31. package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
  32. package/features/messaging/src/hooks/useChatChannel.ts +18 -23
  33. package/features/messaging/src/messaging-schema.ts +0 -2
  34. package/features/search-bar/feature.json +9 -20
  35. package/features/search-bar/src/InlineSearch.tsx +101 -0
  36. package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
  37. package/features/search-bar/src/search-model.ts +2 -149
  38. package/package.json +1 -1
  39. package/features/messaging/src/ChatMultiPage.tsx +0 -298
  40. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
  41. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
  42. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
  43. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
  44. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
  45. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
  46. package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
  47. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
  48. package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
  49. package/features/search-bar/src/SearchOverlay.tsx +0 -566
  50. package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
  51. package/features/sidebar/feature.json +0 -19
  52. package/features/sidebar/src/AppSidebar.tsx +0 -558
@@ -1,10 +1,10 @@
1
1
  /**
2
- * useChatChannel — Find or create a default chat channel, and auto-join.
2
+ * useChatChannel — Find or create a default public chat channel.
3
3
  *
4
- * Encapsulates all channel logic so consuming components never need to
5
- * know about the channels collection. On first mount (when no channel
6
- * exists yet) it creates a "general" public channel automatically and
7
- * joins the current user.
4
+ * Encapsulates default-channel discovery so consuming components never need
5
+ * to know about the channels collection. On first mount, a member or admin
6
+ * creates the "general" public channel when none exists. Channel membership
7
+ * remains optional and is not used as a read-access gate.
8
8
  *
9
9
  * Usage:
10
10
  * const { channelId, status } = useChatChannel()
@@ -13,9 +13,8 @@
13
13
  */
14
14
 
15
15
  import { useEffect, useRef } from 'react'
16
- import { useUser } from 'deepspace'
16
+ import { useUser, isWriterRole } from 'deepspace'
17
17
  import { useChannels } from 'deepspace'
18
- import { useChannelMembers } from 'deepspace'
19
18
  import type { Channel } from 'deepspace'
20
19
  import type { RecordData } from 'deepspace'
21
20
 
@@ -24,7 +23,10 @@ const DEFAULT_CHANNEL_NAME = 'general'
24
23
  export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
25
24
  const { user } = useUser()
26
25
  const { channels, status, create } = useChannels()
27
- const hasInitialized = useRef(false)
26
+ // Keyed by channel name, not a boolean: the same mounted hook can be
27
+ // repointed at another channel, which needs its own one-shot create.
28
+ const initializedFor = useRef<string | null>(null)
29
+ const canCreateChannel = isWriterRole(user?.role)
28
30
 
29
31
  // Nothing enforces name uniqueness server-side, so two first visitors can
30
32
  // race the one-shot init below and BOTH create the default channel. With a
@@ -43,32 +45,25 @@ export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
43
45
  a.createdAt.localeCompare(b.createdAt) || a.recordId.localeCompare(b.recordId),
44
46
  )[0]
45
47
 
46
- const { isMember, join, status: membersStatus } = useChannelMembers(defaultChannel?.recordId)
47
-
48
48
  // One-shot initialization: create the default channel if it doesn't exist.
49
49
  useEffect(() => {
50
- if (!user || status !== 'ready' || hasInitialized.current) return
51
- hasInitialized.current = true
50
+ if (!canCreateChannel || status !== 'ready' || initializedFor.current === channelName) return
51
+ initializedFor.current = channelName
52
52
 
53
53
  if (!defaultChannel) {
54
- create({
54
+ void create({
55
55
  name: channelName,
56
- type: 'public',
57
56
  description: 'Default chat channel',
57
+ }).catch(() => {
58
+ // A not-ready write already reaches RecordProvider.onWriteError. Allow
59
+ // a later ready render to retry instead of leaving initialization stuck.
60
+ initializedFor.current = null
58
61
  })
59
62
  }
60
- }, [user, status, defaultChannel, create, channelName])
61
-
62
- // Wait for the members query to be ready before auto-joining.
63
- useEffect(() => {
64
- if (!user || !defaultChannel || isMember || membersStatus !== 'ready') return
65
- join()
66
- }, [user, defaultChannel, isMember, membersStatus, join])
63
+ }, [canCreateChannel, status, defaultChannel, create, channelName])
67
64
 
68
65
  return {
69
66
  channelId: defaultChannel?.recordId,
70
67
  status,
71
- isMember,
72
- join,
73
68
  }
74
69
  }
@@ -11,7 +11,6 @@ import {
11
11
  MESSAGES_SCHEMA,
12
12
  REACTIONS_SCHEMA,
13
13
  CHANNEL_MEMBERS_SCHEMA,
14
- CHANNEL_INVITATIONS_SCHEMA,
15
14
  READ_RECEIPTS_SCHEMA,
16
15
  } from 'deepspace/schema'
17
16
 
@@ -20,6 +19,5 @@ export const messagingSchemas: CollectionSchema[] = [
20
19
  MESSAGES_SCHEMA,
21
20
  REACTIONS_SCHEMA,
22
21
  CHANNEL_MEMBERS_SCHEMA,
23
- CHANNEL_INVITATIONS_SCHEMA,
24
22
  READ_RECEIPTS_SCHEMA,
25
23
  ]
@@ -2,39 +2,28 @@
2
2
  "id": "search-bar",
3
3
  "name": "Search Bar",
4
4
  "category": "data",
5
- "description": "Drop-in InlineSearch/SearchOverlay for app search; use instead of raw SearchInput when showing or filtering results",
6
- "details": "Use this before hand-rolling search over local arrays, feeds, tables, pickers, or RecordRoom records. Installs focused UI and search-model modules for inline or overlay search, ranking, keyboard navigation, grouped results, async search, highlighting, and optional recent searches. It does not add a route or demo page; import the installed modules into the page you are building.",
5
+ "description": "Inline local search with lightweight ranking and highlighting",
6
+ "details": "A controlled InlineSearch component and a small local ranking hook. The feature does not own overlays, persistence, history, networking, navigation, or app state.",
7
7
  "files": [
8
8
  {
9
9
  "src": "src/search-model.ts",
10
10
  "dest": "src/components/search/search-model.ts"
11
11
  },
12
12
  {
13
- "src": "src/SearchOverlay.tsx",
14
- "dest": "src/components/search/SearchOverlay.tsx"
13
+ "src": "src/InlineSearch.tsx",
14
+ "dest": "src/components/search/InlineSearch.tsx"
15
15
  }
16
16
  ],
17
17
  "requires": [],
18
18
  "instructions": [
19
- "Installs src/components/search/SearchOverlay.tsx for UI and src/components/search/search-model.ts for ranking, async search, highlighting, and recent-history behavior. It adds no route.",
20
- "Import InlineSearch, SearchOverlay, or SearchResultList from @/components/search/SearchOverlay; import useSearchIndex, useAsyncSearch, or getHighlightedParts from @/components/search/search-model.",
21
- "Use InlineSearch + useSearchIndex for in-page filtering (lists, feeds, tables, pickers, RecordRoom records); use SearchOverlay for global/command-style search.",
22
- "Use the raw SearchInput primitive only for a bare input with no result list, ranking, keyboard navigation, or selection handling."
19
+ "Import InlineSearch from @/components/search/InlineSearch and useSearchIndex from @/components/search/search-model.",
20
+ "Keep source data, async fetching, navigation, and persistence in the page that owns the search experience."
23
21
  ],
24
22
  "agentNotes": [
25
- "Use InlineSearch when search belongs to a specific page section and results should appear beneath that input; use SearchOverlay when search is temporary, global, command-like, includes recent searches, submits partial text to a search-results page, or should interrupt the current workflow.",
26
- "Pass onSearchSubmit when pressing Enter should navigate to or render a full search-results page for the typed query; result clicks still call onSelect.",
27
- "Ask the user which placement they prefer only when both overlay and inline search are plausible and the page context does not make the placement obvious.",
28
- "Use SearchOverlay's recentStorageKey, maxRecentItems, and showRecentSearches props when recent selections or submitted text queries should appear before the user types; keep recent searches off inline controls unless the surrounding UI has room for them.",
29
- "Use getHighlightedParts with renderTitle, renderSubtitle, or renderDescription when matching text should be highlighted; leave highlighting to app-specific renderers so colors, emphasis, and density match the local design."
23
+ "Use this feature for local list, feed, table, picker, or RecordRoom filtering. Build a product-specific overlay only when the app actually needs one."
30
24
  ],
31
25
  "patterns": [
32
- "For local feed/list filtering, keep the source array in the page, derive results with useSearchIndex, map results to SearchItem[], and render InlineSearch where the user expects to search.",
33
- "For RecordRoom data, call useQuery in the page and pass records mapped to SearchItem[] into InlineSearch or SearchOverlay.",
34
- "For overlay search that previews matching items but routes Enter to a results page, pass onSearchSubmit={(query) => navigate(`/search?q=${encodeURIComponent(query)}`)} and keep onSelect for direct result clicks.",
35
- "Keep selections app-specific: create or update records in the onSelect handler only after the user explicitly chooses a result.",
36
- "Use mocked or debounced async providers during prototyping before wiring live integration calls.",
37
- "For privacy-sensitive search contexts, set showRecentSearches={false} or use a scoped recentStorageKey per page or account context."
26
+ "Call useSearchIndex in the page, map results to SearchItem[], and pass the controlled query and selection handler to InlineSearch."
38
27
  ],
39
- "example": "import { InlineSearch } from '@/components/search/SearchOverlay'\nimport { useSearchIndex } from '@/components/search/search-model'\n\nconst results = useSearchIndex({\n items: posts,\n query,\n getText: (post) => [post.title, post.body, post.authorName],\n})\n\n<InlineSearch\n query={query}\n onQueryChange={setQuery}\n items={results.map((post) => ({\n id: post.id,\n title: post.title,\n subtitle: post.authorName,\n description: post.body,\n }))}\n onSelect={(item) => openPost(item.id)}\n placeholder=\"Search posts...\"\n/>"
28
+ "example": "import { InlineSearch } from '@/components/search/InlineSearch'\nimport { useSearchIndex } from '@/components/search/search-model'\n\nconst results = useSearchIndex({ items: posts, query, getText: (post) => [post.title, post.body] })\n\n<InlineSearch query={query} onQueryChange={setQuery} items={results.map((post) => ({ id: post.id, title: post.title, description: post.body }))} onSelect={(item) => openPost(item.id)} />"
40
29
  }
@@ -0,0 +1,101 @@
1
+ import type { ReactNode } from 'react'
2
+ import { EmptyState, SearchInput, cn } from '@/components/ui'
3
+ import { Loader2 } from 'lucide-react'
4
+ import { getHighlightedParts } from './search-model'
5
+
6
+ export interface SearchItem {
7
+ id: string
8
+ title: string
9
+ subtitle?: string
10
+ description?: string
11
+ meta?: ReactNode
12
+ disabled?: boolean
13
+ }
14
+
15
+ export interface InlineSearchProps<T extends SearchItem = SearchItem> {
16
+ query: string
17
+ onQueryChange: (query: string) => void
18
+ items: T[]
19
+ onSelect: (item: T) => void
20
+ placeholder?: string
21
+ label?: string
22
+ loading?: boolean
23
+ error?: string | null
24
+ emptyTitle?: string
25
+ className?: string
26
+ }
27
+
28
+ /** A small, controlled search input and result list for one page section. */
29
+ export function InlineSearch<T extends SearchItem = SearchItem>({
30
+ query,
31
+ onQueryChange,
32
+ items,
33
+ onSelect,
34
+ placeholder = 'Search...',
35
+ label,
36
+ loading = false,
37
+ error = null,
38
+ emptyTitle = 'No matches',
39
+ className,
40
+ }: InlineSearchProps<T>) {
41
+ return (
42
+ <section className={cn('space-y-2', className)}>
43
+ {label && <label className="text-sm font-medium text-foreground">{label}</label>}
44
+ <SearchInput
45
+ value={query}
46
+ onChange={(event) => onQueryChange(event.target.value)}
47
+ onClear={() => onQueryChange('')}
48
+ placeholder={placeholder}
49
+ aria-label={label ?? placeholder}
50
+ />
51
+
52
+ {loading ? (
53
+ <div className="flex items-center gap-2 p-3 text-sm text-muted-foreground">
54
+ <Loader2 className="size-4 animate-spin" /> Searching...
55
+ </div>
56
+ ) : error ? (
57
+ <p role="alert" className="p-3 text-sm text-destructive">
58
+ {error}
59
+ </p>
60
+ ) : items.length === 0 ? (
61
+ <EmptyState title={emptyTitle} description="Try another search term." />
62
+ ) : (
63
+ <ul className="max-h-80 overflow-y-auto rounded-lg border border-border bg-card">
64
+ {items.map((item) => (
65
+ <li key={item.id}>
66
+ <button
67
+ type="button"
68
+ disabled={item.disabled}
69
+ onClick={() => onSelect(item)}
70
+ className="flex w-full items-start justify-between gap-3 border-b border-border p-3 text-left last:border-b-0 hover:bg-muted/60 disabled:cursor-not-allowed disabled:opacity-50"
71
+ >
72
+ <span className="min-w-0">
73
+ <span className="block text-sm font-medium text-foreground">
74
+ <HighlightedText text={item.title} query={query} />
75
+ </span>
76
+ {item.subtitle && (
77
+ <span className="mt-0.5 block text-xs text-muted-foreground">
78
+ <HighlightedText text={item.subtitle} query={query} />
79
+ </span>
80
+ )}
81
+ {item.description && (
82
+ <span className="mt-1 line-clamp-2 block text-sm text-muted-foreground">
83
+ {item.description}
84
+ </span>
85
+ )}
86
+ </span>
87
+ {item.meta}
88
+ </button>
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ )}
93
+ </section>
94
+ )
95
+ }
96
+
97
+ function HighlightedText({ text, query }: { text: string; query: string }) {
98
+ return getHighlightedParts(text, query).map((part, index) =>
99
+ part.match ? <mark key={index}>{part.text}</mark> : <span key={index}>{part.text}</span>,
100
+ )
101
+ }
@@ -0,0 +1,38 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { getHighlightedParts, useSearchIndex } from '../search-model'
4
+
5
+ const items = [
6
+ { id: 'middle', title: 'Notes about sheep' },
7
+ { id: 'exact', title: 'Sheep' },
8
+ { id: 'prefix', title: 'Sheep counter' },
9
+ ]
10
+
11
+ function ranked(query: string, limit?: number) {
12
+ let result = items
13
+ function Probe() {
14
+ result = useSearchIndex({ items, query, getText: (item) => [item.title], limit })
15
+ return null
16
+ }
17
+ renderToStaticMarkup(<Probe />)
18
+ return result
19
+ }
20
+
21
+ describe('inline search model', () => {
22
+ it('ranks exact and prefix matches before contained matches', () => {
23
+ expect(ranked('sheep').map((item) => item.id)).toEqual(['exact', 'prefix', 'middle'])
24
+ })
25
+
26
+ it('applies the result limit to searches and empty queries', () => {
27
+ expect(ranked('sheep', 2)).toHaveLength(2)
28
+ expect(ranked('', 2)).toEqual(items.slice(0, 2))
29
+ })
30
+
31
+ it('highlights every case-insensitive match without dropping text', () => {
32
+ expect(getHighlightedParts('Sheep sheep', 'sheep')).toEqual([
33
+ { text: 'Sheep', match: true },
34
+ { text: ' ', match: false },
35
+ { text: 'sheep', match: true },
36
+ ])
37
+ })
38
+ })
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useMemo } from 'react'
2
2
 
3
3
  export interface HighlightedPart {
4
4
  text: string
@@ -12,6 +12,7 @@ export interface UseSearchIndexOptions<T> {
12
12
  limit?: number
13
13
  }
14
14
 
15
+ /** Rank a local list without owning app state, persistence, or networking. */
15
16
  export function useSearchIndex<T>({
16
17
  items,
17
18
  query,
@@ -58,154 +59,6 @@ export function getHighlightedParts(text: string, query: string): HighlightedPar
58
59
  return parts.length > 0 ? parts : [{ text, match: false }]
59
60
  }
60
61
 
61
- export interface UseAsyncSearchOptions<T> {
62
- query: string
63
- search: (query: string) => Promise<T[]>
64
- minLength?: number
65
- delayMs?: number
66
- }
67
-
68
- export function useAsyncSearch<T>({
69
- query,
70
- search,
71
- minLength = 2,
72
- delayMs = 250,
73
- }: UseAsyncSearchOptions<T>) {
74
- const [items, setItems] = useState<T[]>([])
75
- const [loading, setLoading] = useState(false)
76
- const [error, setError] = useState<string | null>(null)
77
- const requestId = useRef(0)
78
-
79
- useEffect(() => {
80
- const trimmed = query.trim()
81
- const currentRequest = ++requestId.current
82
- const requiredLength = Number.isFinite(minLength) ? Math.max(0, Math.floor(minLength)) : 2
83
- const waitMs = Number.isFinite(delayMs)
84
- ? Math.min(60_000, Math.max(0, Math.floor(delayMs)))
85
- : 250
86
-
87
- if (trimmed.length < requiredLength) {
88
- setItems([])
89
- setLoading(false)
90
- setError(null)
91
- return
92
- }
93
-
94
- setLoading(true)
95
- setError(null)
96
-
97
- const timer = window.setTimeout(() => {
98
- void search(trimmed)
99
- .then((results) => {
100
- if (requestId.current !== currentRequest) return
101
- setItems(results)
102
- setError(null)
103
- })
104
- .catch((searchError) => {
105
- if (requestId.current !== currentRequest) return
106
- setItems([])
107
- setError(searchError instanceof Error ? searchError.message : 'Search failed')
108
- })
109
- .finally(() => {
110
- if (requestId.current === currentRequest) setLoading(false)
111
- })
112
- }, waitMs)
113
-
114
- return () => {
115
- window.clearTimeout(timer)
116
- if (requestId.current === currentRequest) requestId.current += 1
117
- }
118
- }, [delayMs, minLength, query, search])
119
-
120
- return { items, loading, error }
121
- }
122
-
123
- export type RecentSearchEntry = { kind: 'item'; itemId: string } | { kind: 'query'; query: string }
124
-
125
- export function createRecentStorageKey(
126
- title: string,
127
- placeholder: string,
128
- triggerLabel: string,
129
- ): string {
130
- const scope =
131
- [title, placeholder, triggerLabel].map((value) => value.trim().toLowerCase()).find(Boolean) ??
132
- 'search'
133
- const normalizedScope = scope.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'search'
134
- return `deepspace:search-bar:recent:${normalizedScope}`
135
- }
136
-
137
- export function readRecentSearches(storageKey: string, maxItems: number): RecentSearchEntry[] {
138
- try {
139
- const parsed: unknown = JSON.parse(window.localStorage.getItem(storageKey) ?? '[]')
140
- if (!Array.isArray(parsed)) return []
141
- return parsed.filter(isRecentSearchEntry).slice(0, normalizeLimit(maxItems))
142
- } catch {
143
- return []
144
- }
145
- }
146
-
147
- export function rememberRecentItem(
148
- storageKey: string,
149
- itemId: string,
150
- maxItems: number,
151
- ): RecentSearchEntry[] {
152
- if (!itemId) return readRecentSearches(storageKey, maxItems)
153
- return rememberRecent(
154
- storageKey,
155
- { kind: 'item', itemId },
156
- (entry) => entry.kind === 'item' && entry.itemId === itemId,
157
- maxItems,
158
- )
159
- }
160
-
161
- export function rememberRecentQuery(
162
- storageKey: string,
163
- rawQuery: string,
164
- maxItems: number,
165
- ): RecentSearchEntry[] {
166
- const query = rawQuery.trim()
167
- const normalizedQuery = normalize(query)
168
- if (!normalizedQuery) return readRecentSearches(storageKey, maxItems)
169
- return rememberRecent(
170
- storageKey,
171
- { kind: 'query', query },
172
- (entry) => entry.kind === 'query' && normalize(entry.query) === normalizedQuery,
173
- maxItems,
174
- )
175
- }
176
-
177
- function rememberRecent(
178
- storageKey: string,
179
- recent: RecentSearchEntry,
180
- matches: (entry: RecentSearchEntry) => boolean,
181
- maxItems: number,
182
- ): RecentSearchEntry[] {
183
- const next = [
184
- recent,
185
- ...readRecentSearches(storageKey, maxItems).filter((entry) => !matches(entry)),
186
- ].slice(0, normalizeLimit(maxItems))
187
-
188
- try {
189
- window.localStorage.setItem(storageKey, JSON.stringify(next))
190
- } catch {
191
- // Persistence is optional; search continues with the in-memory result.
192
- }
193
- return next
194
- }
195
-
196
- function isRecentSearchEntry(value: unknown): value is RecentSearchEntry {
197
- if (!value || typeof value !== 'object') return false
198
- const candidate = value as Partial<RecentSearchEntry>
199
- if (candidate.kind === 'item') return typeof candidate.itemId === 'string' && !!candidate.itemId
200
- return (
201
- candidate.kind === 'query' && typeof candidate.query === 'string' && !!candidate.query.trim()
202
- )
203
- }
204
-
205
- function normalizeLimit(value: number): number {
206
- return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
207
- }
208
-
209
62
  function normalize(value: string): string {
210
63
  return value.trim().toLowerCase()
211
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepspace",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "engines": {
5
5
  "node": ">=22.15.0 <23 || >=24 <25 || >=26 <27"
6
6
  },