@asaidimu/utils-workspace 1.0.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.
- package/LICENSE.md +21 -0
- package/README.md +443 -0
- package/index.d.mts +899 -0
- package/index.d.ts +899 -0
- package/index.js +1 -0
- package/index.mjs +1 -0
- package/package.json +48 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Saidimu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
# @asaidimu/utils-workspace
|
|
2
|
+
|
|
3
|
+
Content-addressed workspace and conversation management for AI applications. Handles roles, preferences, context, transcripts, and binary blobs with a unified storage model.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@asaidimu/utils-workspace)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/asaidimu/utils-workspace/actions)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
- [Overview & Features](#overview--features)
|
|
13
|
+
- [Installation & Setup](#installation--setup)
|
|
14
|
+
- [Usage Documentation](#usage-documentation)
|
|
15
|
+
- [Project Architecture](#project-architecture)
|
|
16
|
+
- [Development & Contributing](#development--contributing)
|
|
17
|
+
- [Additional Information](#additional-information)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Overview & Features
|
|
22
|
+
|
|
23
|
+
**@asaidimu/utils-workspace** provides a complete solution for managing conversational state in AI applications. Built on a content-addressed storage model, it handles everything from role definitions and user preferences to session transcripts and binary blobs. The library separates concerns between index state (fast, always in memory) and content storage (asynchronous, backend-agnostic), enabling efficient operation in both browser and Node.js environments.
|
|
24
|
+
|
|
25
|
+
The system implements a command-reducer pattern where state mutations are expressed as commands that produce patches. This design enables optimistic UI updates, offline-first operation, and easy persistence snapshots. Blob management uses SHA-256 content addressing for automatic deduplication, ref counting, and remote ID tracking across providers like Anthropic or OpenAI.
|
|
26
|
+
|
|
27
|
+
### Key Features
|
|
28
|
+
|
|
29
|
+
- **Content-Addressed Blob Storage** — SHA-256 based deduplication, reference counting, and atomic storage with IndexedDB or memory backends
|
|
30
|
+
- **Turn-Based Transcripts** — Versioned conversation trees with branching, editing, and subtree deletion
|
|
31
|
+
- **Rich Content Blocks** — Support for text, images, documents, tool use, and thinking blocks with token estimation
|
|
32
|
+
- **Preference & Context Management** — Conflict resolution with timestamps, topic-based filtering, and relevance scoring with freshness decay
|
|
33
|
+
- **Prompt Building** — Token budget allocation, transcript summarization, and context ranking that produces provider-agnostic prompts
|
|
34
|
+
- **Multiple Storage Backends** — IndexedDB for browsers, MemoryStorage for testing and server-side use
|
|
35
|
+
- **TypeScript First** — Full type definitions with strict mode compatibility
|
|
36
|
+
- **Offline-Ready** — Dirty buffer management with configurable flush strategies and optimistic updates
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation & Setup
|
|
41
|
+
|
|
42
|
+
### Prerequisites
|
|
43
|
+
|
|
44
|
+
- Node.js 18+ (for server-side usage) or modern browser with Web Crypto API support
|
|
45
|
+
- TypeScript 5.0+ (optional, for type safety)
|
|
46
|
+
|
|
47
|
+
### Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install @asaidimu/utils-workspace
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
or with yarn:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
yarn add @asaidimu/utils-workspace
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Basic Setup
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import {
|
|
63
|
+
IndexedDBStorage,
|
|
64
|
+
ContentStore,
|
|
65
|
+
WorkspaceManager,
|
|
66
|
+
PromptBuilder,
|
|
67
|
+
MemoryBlobStorage,
|
|
68
|
+
BlobStore
|
|
69
|
+
merge,
|
|
70
|
+
} from '@asaidimu/utils-workspace';
|
|
71
|
+
|
|
72
|
+
// Initialize content storage (IndexedDB for persistence)
|
|
73
|
+
const contentBackend = new IndexedDBStorage({ dbName: 'my-workspace' });
|
|
74
|
+
await contentBackend.open();
|
|
75
|
+
|
|
76
|
+
// Initialize blob storage (memory for this example, IndexedDB also available)
|
|
77
|
+
const blobBackend = new MemoryBlobStorage();
|
|
78
|
+
const blobStore = new BlobStore(blobBackend);
|
|
79
|
+
await blobStore.init();
|
|
80
|
+
|
|
81
|
+
// Create content store with default configuration
|
|
82
|
+
const contentStore = new ContentStore(contentBackend);
|
|
83
|
+
|
|
84
|
+
// Create workspace manager
|
|
85
|
+
const manager = new WorkspaceManager(contentStore);
|
|
86
|
+
|
|
87
|
+
// Initialize empty index state
|
|
88
|
+
const emptyState = {
|
|
89
|
+
format: 'aiworkspace/4.0',
|
|
90
|
+
workspace: {
|
|
91
|
+
id: 'ws-1',
|
|
92
|
+
settings: { language: 'en', defaultRole: 'assistant' },
|
|
93
|
+
project: { name: 'My Project', owner: 'user' },
|
|
94
|
+
indexes: { sessions: {}, roles: {}, preferences: {}, context: {}, topics: {} }
|
|
95
|
+
},
|
|
96
|
+
roles: {},
|
|
97
|
+
blobs: {},
|
|
98
|
+
preferences: {},
|
|
99
|
+
context: {},
|
|
100
|
+
sessions: {}
|
|
101
|
+
};
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Usage Documentation
|
|
107
|
+
|
|
108
|
+
### Basic Usage
|
|
109
|
+
|
|
110
|
+
Create a role, session, and add a turn:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
let indexState = emptyState;
|
|
114
|
+
|
|
115
|
+
// 1. Add a role
|
|
116
|
+
const addRoleCmd = {
|
|
117
|
+
type: 'role:add' as const,
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
payload: {
|
|
120
|
+
id: 'role-1',
|
|
121
|
+
name: 'assistant',
|
|
122
|
+
label: 'Assistant',
|
|
123
|
+
persona: 'You are a helpful assistant.',
|
|
124
|
+
preferences: []
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const roleResult = await manager.dispatch(indexState, addRoleCmd);
|
|
129
|
+
if (roleResult.ok) {
|
|
130
|
+
// Merge the patch into your state
|
|
131
|
+
indexState = merge(indexState, roleResult.value) ;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2. Create a session
|
|
135
|
+
const createSessionCmd = {
|
|
136
|
+
type: 'session:create' as const,
|
|
137
|
+
timestamp: new Date().toISOString(),
|
|
138
|
+
payload: {
|
|
139
|
+
id: 'session-1',
|
|
140
|
+
label: 'My First Conversation',
|
|
141
|
+
role: 'assistant',
|
|
142
|
+
topics: ['general'],
|
|
143
|
+
preferences: []
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const sessionResult = await manager.dispatch(indexState, createSessionCmd);
|
|
148
|
+
if (sessionResult.ok) {
|
|
149
|
+
indexState = merge(indexState, sessionResult.value) ;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Activate the session (enables dirty buffer for performance)
|
|
153
|
+
await manager.activateSession(indexState, 'session-1');
|
|
154
|
+
|
|
155
|
+
// 4. Add a turn
|
|
156
|
+
const addTurnCmd = {
|
|
157
|
+
type: 'turn:add' as const,
|
|
158
|
+
timestamp: new Date().toISOString(),
|
|
159
|
+
payload: {
|
|
160
|
+
sessionId: 'session-1',
|
|
161
|
+
turn: {
|
|
162
|
+
id: 'turn-1',
|
|
163
|
+
version: 0,
|
|
164
|
+
role: 'user',
|
|
165
|
+
blocks: [{ type: 'text', text: 'Hello, how are you?' }],
|
|
166
|
+
timestamp: new Date().toISOString(),
|
|
167
|
+
roleSnapshot: 'assistant',
|
|
168
|
+
parent: null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
await manager.dispatch(indexState, addTurnCmd);
|
|
174
|
+
await manager.flush(); // Persist dirty buffer to storage
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Registering and Using Blobs
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// Register a blob (e.g., an image or document)
|
|
181
|
+
const imageData = new TextEncoder().encode('fake-image-data');
|
|
182
|
+
const blobResult = await blobStore.register(imageData, 'image/jpeg', 'photo.jpg');
|
|
183
|
+
|
|
184
|
+
if (blobResult.ok) {
|
|
185
|
+
const blobRef = blobResult.value;
|
|
186
|
+
|
|
187
|
+
// Use the blob in a turn
|
|
188
|
+
const turnWithBlob = {
|
|
189
|
+
id: 'turn-2',
|
|
190
|
+
version: 0,
|
|
191
|
+
role: 'user',
|
|
192
|
+
blocks: [
|
|
193
|
+
{ type: 'text', text: 'What is in this image?' },
|
|
194
|
+
{ type: 'image', blob: blobRef }
|
|
195
|
+
],
|
|
196
|
+
timestamp: new Date().toISOString(),
|
|
197
|
+
roleSnapshot: 'assistant',
|
|
198
|
+
parent: null
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
await manager.dispatch(indexState, {
|
|
202
|
+
type: 'turn:add',
|
|
203
|
+
timestamp: new Date().toISOString(),
|
|
204
|
+
payload: { sessionId: 'session-1', turn: turnWithBlob }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Building Prompts
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
// Resolve the session to get effective preferences, context, and transcript
|
|
213
|
+
const resolved = await manager.resolveSession(indexState, 'session-1');
|
|
214
|
+
|
|
215
|
+
if (resolved.ok) {
|
|
216
|
+
// Resolve all blobs in the session
|
|
217
|
+
const refs = resolved.value.transcript
|
|
218
|
+
.flatMap(t => t.blocks.filter(b => b.type === 'image' || b.type === 'document'))
|
|
219
|
+
.map(b => b.blob);
|
|
220
|
+
|
|
221
|
+
const { resolved: resolvedBlobs, errors } = await blobStore.resolveRefs(refs, null);
|
|
222
|
+
|
|
223
|
+
// Build the prompt
|
|
224
|
+
const builder = new PromptBuilder();
|
|
225
|
+
const prompt = await builder.build(resolved.value, {
|
|
226
|
+
resolvedBlobs,
|
|
227
|
+
tokenBudget: { total: 4000 }
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
console.log(prompt.system); // System prompt with persona, preferences, context
|
|
231
|
+
console.log(prompt.turns); // Conversation turns with resolved blobs
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Configuration
|
|
236
|
+
|
|
237
|
+
**ContentStore Configuration**
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
const contentStore = new ContentStore(backend, {
|
|
241
|
+
cache: {
|
|
242
|
+
roles: 10, // Max roles in LRU cache
|
|
243
|
+
preferences: 50, // Max preferences in LRU cache
|
|
244
|
+
contextEntries: 20, // Max context entries in LRU cache
|
|
245
|
+
transcriptWindows: 3 // Max transcript windows in LRU cache
|
|
246
|
+
},
|
|
247
|
+
flush: {
|
|
248
|
+
maxBufferSize: 10, // Auto-flush after 10 turns
|
|
249
|
+
flushIntervalMs: 30000 // Auto-flush every 30 seconds
|
|
250
|
+
},
|
|
251
|
+
transcriptWindowSize: 20 // Default turns per window
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**BlobStore Configuration**
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
const blobStore = new BlobStore(backend, {
|
|
259
|
+
eagerEviction: false // When false, blobs with refCount 0 persist until gc()
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
**IndexedDB Configuration**
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
const contentBackend = new IndexedDBStorage({ dbName: 'custom-db-name' });
|
|
267
|
+
const blobBackend = new IndexedDBBlobStorage({ dbName: 'custom-blob-db' });
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Project Architecture
|
|
273
|
+
|
|
274
|
+
### Core Components
|
|
275
|
+
|
|
276
|
+
**IndexState** — The in-memory representation of the workspace. Contains summaries of all entities (roles, preferences, contexts, sessions, blobs) and is designed for fast reads. State mutations produce patches that can be merged optimistically.
|
|
277
|
+
|
|
278
|
+
**ContentStore** — Manages asynchronous access to persisted content. Implements LRU caching with pinning for active sessions, dirty buffer management for turns, and maintains the distinction between index state and full content.
|
|
279
|
+
|
|
280
|
+
**WorkspaceManager** — The public facade that coordinates between commands, reducer, and content persistence. Handles side effects (saving to storage) while keeping the reducer pure.
|
|
281
|
+
|
|
282
|
+
**BlobStore** — Content-addressed blob management with SHA-256 deduplication, reference counting, remote ID tracking, and resolution between inline and remote blob references.
|
|
283
|
+
|
|
284
|
+
**PromptBuilder** — Transforms an EffectiveSession into a provider-agnostic prompt structure with token budgeting, preference conflict resolution, context ranking, and optional summarization.
|
|
285
|
+
|
|
286
|
+
**Storage Backends** — Pluggable storage layers:
|
|
287
|
+
- **MemoryStorage** — In-memory implementation for testing
|
|
288
|
+
- **IndexedDBStorage** — Persistent browser storage for content
|
|
289
|
+
- **MemoryBlobStorage** — In-memory blob storage
|
|
290
|
+
- **IndexedDBBlobStorage** — Persistent browser storage for blobs
|
|
291
|
+
|
|
292
|
+
### Data Flow
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
Command → WorkspaceManager.dispatch()
|
|
296
|
+
↓
|
|
297
|
+
WorkspaceReducer (pure patch generation)
|
|
298
|
+
↓
|
|
299
|
+
ContentStore (side effects: save roles, preferences, turns, etc.)
|
|
300
|
+
↓
|
|
301
|
+
Storage Backend (IndexedDB / Memory)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
For resolution and prompt building:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
IndexState + SessionId → ContentStore.resolveSession()
|
|
308
|
+
↓ (async content loading)
|
|
309
|
+
EffectiveSession → BlobStore.resolveRefs()
|
|
310
|
+
↓ (blob resolution)
|
|
311
|
+
PromptBuilder.build()
|
|
312
|
+
↓ (token budgeting, summarization)
|
|
313
|
+
Prompt (system, turns, warnings)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Extension Points
|
|
317
|
+
|
|
318
|
+
**Custom Storage Backends** — Implement the `ContentStorage` or `BlobStorage` interfaces to support alternative persistence (SQLite, cloud storage, etc.)
|
|
319
|
+
|
|
320
|
+
**Summarizer** — Inject a custom summarizer into `PromptBuilder` for transcript compression:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
interface Summarizer {
|
|
324
|
+
summarize(transcript: Turn[], tokenBudget: number): Promise<{
|
|
325
|
+
summary: string;
|
|
326
|
+
remaining: Turn[];
|
|
327
|
+
}>;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const builder = new PromptBuilder(myCustomSummarizer);
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
**Token Estimators** — Override the default text estimator for provider-specific token counting:
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
const prompt = await builder.build(session, {
|
|
337
|
+
tokenBudget: {
|
|
338
|
+
total: 8000,
|
|
339
|
+
estimator: (text) => myProviderTokenCounter.count(text)
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Development & Contributing
|
|
347
|
+
|
|
348
|
+
### Development Setup
|
|
349
|
+
|
|
350
|
+
1. Clone the repository:
|
|
351
|
+
```bash
|
|
352
|
+
git clone https://github.com/asaidimu/utils-workspace.git
|
|
353
|
+
cd utils-workspace
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
2. Install dependencies:
|
|
357
|
+
```bash
|
|
358
|
+
npm install
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
3. Build the project:
|
|
362
|
+
```bash
|
|
363
|
+
npm run build
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Available Scripts
|
|
367
|
+
|
|
368
|
+
| Script | Description |
|
|
369
|
+
|--------|-------------|
|
|
370
|
+
| `npm run build` | Compile TypeScript to JavaScript |
|
|
371
|
+
| `npm run test` | Run all tests with Vitest |
|
|
372
|
+
| `npm run test:watch` | Run tests in watch mode |
|
|
373
|
+
| `npm run lint` | Run ESLint |
|
|
374
|
+
| `npm run format` | Format code with Prettier |
|
|
375
|
+
| `npm run typecheck` | Run TypeScript type checking |
|
|
376
|
+
|
|
377
|
+
### Testing
|
|
378
|
+
|
|
379
|
+
The test suite uses Vitest with fake-indexeddb for browser environment simulation. Run tests with:
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
npm run test
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Coverage reports can be generated with:
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
npm run test:coverage
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Contributing Guidelines
|
|
392
|
+
|
|
393
|
+
1. **Fork and Branch** — Create a feature branch from `main`
|
|
394
|
+
2. **Write Tests** — Add tests for new functionality or bug fixes
|
|
395
|
+
3. **Update Documentation** — Keep README and JSDoc comments current
|
|
396
|
+
4. **Commit Convention** — Use conventional commits (feat:, fix:, docs:, etc.)
|
|
397
|
+
5. **Submit PR** — Open a pull request with a clear description of changes
|
|
398
|
+
|
|
399
|
+
### Issue Reporting
|
|
400
|
+
|
|
401
|
+
- **Bug Reports** — Include minimal reproduction, expected vs actual behavior, and environment details
|
|
402
|
+
- **Feature Requests** — Describe use case and expected API
|
|
403
|
+
- **Security Issues** — Email maintainers directly (see package.json for contact)
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Additional Information
|
|
408
|
+
|
|
409
|
+
### Troubleshooting
|
|
410
|
+
|
|
411
|
+
**Q: Blob bytes not found locally error**
|
|
412
|
+
|
|
413
|
+
Blobs with refCount 0 are eligible for garbage collection. If you need them later, ensure:
|
|
414
|
+
- `eagerEviction: false` in BlobStore config
|
|
415
|
+
- `gc()` is not called before resolution
|
|
416
|
+
- Register the blob again if it was evicted
|
|
417
|
+
|
|
418
|
+
**Q: IndexedDB open fails with "blocked" error**
|
|
419
|
+
|
|
420
|
+
Another browser tab has an open connection to the same database. Close the other tab or wait for it to release the connection.
|
|
421
|
+
|
|
422
|
+
**Q: Turn edits not persisting**
|
|
423
|
+
|
|
424
|
+
Ensure `flush()` is called after batch operations, or configure `flush.maxBufferSize` to auto-flush.
|
|
425
|
+
|
|
426
|
+
**Q: TypeScript errors with custom storage backends**
|
|
427
|
+
|
|
428
|
+
Import the interface types and implement all required methods:
|
|
429
|
+
|
|
430
|
+
```typescript
|
|
431
|
+
import type { ContentStorage, BlobStorage } from '@asaidimu/utils-workspace';
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### License
|
|
435
|
+
|
|
436
|
+
MIT © [Saidimu](https://github.com/asaidimu)
|
|
437
|
+
|
|
438
|
+
See the [LICENSE](LICENSE) file for details.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
**Related Projects**
|
|
443
|
+
- [@asaidimu/utils-store](https://github.com/asaidimu/utils-store) — Deep merge utilities and store patterns
|