duet-kit 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.
- package/README.md +442 -0
- package/dist/attach.d.ts +48 -0
- package/dist/attach.d.ts.map +1 -0
- package/dist/attach.js +205 -0
- package/dist/duet.d.ts +63 -0
- package/dist/duet.d.ts.map +1 -0
- package/dist/duet.js +259 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/llm.d.ts +25 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +170 -0
- package/dist/schema.d.ts +27 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +122 -0
- package/dist/store.d.ts +18 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +58 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# duet-kit
|
|
2
|
+
|
|
3
|
+
**Shared state for humans and AI.** A Zustand store that both can edit, with validation and an audit trail.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
You're building a UI where humans and AI edit the same state—a configuration wizard with AI suggestions, a form where AI auto-fills based on conversation, an editor where AI can make changes alongside you.
|
|
8
|
+
|
|
9
|
+
The AI needs:
|
|
10
|
+
- Schema context to know what fields exist
|
|
11
|
+
- Validation to prevent invalid edits
|
|
12
|
+
- A structured format to apply changes
|
|
13
|
+
|
|
14
|
+
You end up writing boilerplate: prompt templates, JSON parsing, validation logic, error handling. Every team does this differently. You can absolutely build this yourself—duet-kit just makes it simpler.
|
|
15
|
+
|
|
16
|
+
## The Solution
|
|
17
|
+
|
|
18
|
+
**duet-kit** gives your Zustand store an LLM interface. One schema, two editors (human + AI), same validation rules. State changes are instant—no round-trip to a server.
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { createDuet, field, z } from 'duet-kit'
|
|
22
|
+
|
|
23
|
+
const useFormStore = createDuet('ContactForm', {
|
|
24
|
+
name: field(z.string().min(1), 'Full Name', ''),
|
|
25
|
+
email: field(z.string().email(), 'Email', ''),
|
|
26
|
+
company: field(z.string(), 'Company', ''),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// React UI uses it like any Zustand store
|
|
30
|
+
const { data, set } = useFormStore()
|
|
31
|
+
|
|
32
|
+
// LLM uses the attached bridge
|
|
33
|
+
useFormStore.llm.getContext() // schema + current values for prompt
|
|
34
|
+
useFormStore.llm.applyJSON('[{"op":"replace",...}]') // JSON Patch from LLM
|
|
35
|
+
useFormStore.llm.getFunctionSchema() // OpenAI function calling format
|
|
36
|
+
useFormStore.llm.history() // audit trail of all patches
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Zustand-based** — Works like any Zustand store. No new patterns to learn.
|
|
42
|
+
- **Zod validation** — Schema defines both types and constraints. LLM edits are validated.
|
|
43
|
+
- **JSON Patch (RFC 6902)** — Standard format for edits. Supports nested fields and LLM know it already.
|
|
44
|
+
- **Audit trail** — Every patch logged with timestamp, source, and result.
|
|
45
|
+
- **Drop-in ready** — `attachLLM()` adds capabilities to existing Zustand + Zod code.
|
|
46
|
+
- **TypeScript** — Full type inference from your schema.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install duet-kit
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Peer dependencies: `react`, `zustand`, `zod`
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { createDuet, field, z } from 'duet-kit'
|
|
62
|
+
|
|
63
|
+
// Define schema with nested fields
|
|
64
|
+
const useStore = createDuet('TripBudget', {
|
|
65
|
+
destination: field(z.string().min(1), 'Destination', 'Tokyo'),
|
|
66
|
+
days: field(z.number().min(1).max(365), 'Duration', 7),
|
|
67
|
+
accommodation: field(
|
|
68
|
+
z.object({
|
|
69
|
+
type: z.enum(['hotel', 'airbnb', 'hostel']),
|
|
70
|
+
budgetPerNight: z.number().min(0),
|
|
71
|
+
}),
|
|
72
|
+
'Accommodation',
|
|
73
|
+
{ type: 'hotel', budgetPerNight: 150 }
|
|
74
|
+
),
|
|
75
|
+
}, { persist: 'trip-data' })
|
|
76
|
+
|
|
77
|
+
// React component
|
|
78
|
+
function TripForm() {
|
|
79
|
+
const { data, set } = useStore()
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<input
|
|
83
|
+
value={data.destination}
|
|
84
|
+
onChange={e => set('destination', e.target.value)}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// LLM integration
|
|
90
|
+
const result = useStore.llm.applyJSON(`[
|
|
91
|
+
{ "op": "replace", "path": "/destination", "value": "Paris" },
|
|
92
|
+
{ "op": "replace", "path": "/accommodation/type", "value": "airbnb" }
|
|
93
|
+
]`)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Already Using Zustand + Zod?
|
|
99
|
+
|
|
100
|
+
Add LLM capabilities without changing your existing code:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { create } from 'zustand'
|
|
104
|
+
import { z } from 'zod'
|
|
105
|
+
import { attachLLM } from 'duet-kit'
|
|
106
|
+
|
|
107
|
+
// Your existing store (unchanged)
|
|
108
|
+
const schema = z.object({
|
|
109
|
+
title: z.string(),
|
|
110
|
+
priority: z.number().min(1).max(5),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const useStore = create<z.infer<typeof schema>>()(() => ({
|
|
114
|
+
title: '',
|
|
115
|
+
priority: 1,
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
// One line addition
|
|
119
|
+
const llm = attachLLM(useStore, schema, { name: 'Task' })
|
|
120
|
+
|
|
121
|
+
// Now available:
|
|
122
|
+
llm.getContext() // prompt context
|
|
123
|
+
llm.applyJSON(...) // apply LLM output
|
|
124
|
+
llm.getFunctionSchema() // OpenAI tools format
|
|
125
|
+
llm.history() // audit trail
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
No migration. No rewrites. Your store and schema stay exactly as they are.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## API Reference
|
|
133
|
+
|
|
134
|
+
### `createDuet(name, fields, options?)`
|
|
135
|
+
|
|
136
|
+
Creates a Zustand store with LLM bridge attached.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
const useStore = createDuet('FormName', {
|
|
140
|
+
fieldName: field(zodSchema, 'Label', defaultValue),
|
|
141
|
+
}, {
|
|
142
|
+
persist: 'localStorage-key', // optional localStorage persistence
|
|
143
|
+
transformContext: (ctx) => `Custom instructions...\n\n${ctx}`, // customize LLM prompt
|
|
144
|
+
transformFunctionSchema: (schema) => ({ ...schema, description: 'Custom' }), // customize function schema
|
|
145
|
+
})
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Options:**
|
|
149
|
+
| Option | Description |
|
|
150
|
+
|--------|-------------|
|
|
151
|
+
| `persist` | localStorage key for persistence (omit for in-memory only) |
|
|
152
|
+
| `transformContext` | Transform the default `getContext()` output before returning |
|
|
153
|
+
| `transformFunctionSchema` | Transform the default `getFunctionSchema()` output before returning |
|
|
154
|
+
|
|
155
|
+
Returns a Zustand hook with `.llm` and `.schema` properties.
|
|
156
|
+
|
|
157
|
+
### `field(schema, label, defaultValue)`
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
field(z.string().min(1), 'Username', '')
|
|
161
|
+
field(z.number().min(0).max(100), 'Score', 0)
|
|
162
|
+
field(z.enum(['draft', 'published']), 'Status', 'draft')
|
|
163
|
+
field(z.boolean(), 'Active', true)
|
|
164
|
+
|
|
165
|
+
// Nested objects
|
|
166
|
+
field(z.object({
|
|
167
|
+
street: z.string(),
|
|
168
|
+
city: z.string(),
|
|
169
|
+
}), 'Address', { street: '', city: '' })
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### `attachLLM(store, zodSchema, options?)`
|
|
173
|
+
|
|
174
|
+
For existing Zustand stores:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
const llm = attachLLM(existingStore, existingSchema, {
|
|
178
|
+
name: 'SchemaName',
|
|
179
|
+
labels: { fieldName: 'Human Label' }
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Store API
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
const { data, set, setMany, reset } = useStore()
|
|
189
|
+
|
|
190
|
+
data.fieldName // read
|
|
191
|
+
set('fieldName', value) // write single (returns success boolean)
|
|
192
|
+
setMany({ a: 1, b: 2 }) // write multiple
|
|
193
|
+
reset() // restore defaults
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## LLM Bridge API
|
|
199
|
+
|
|
200
|
+
### `getContext()`
|
|
201
|
+
|
|
202
|
+
Generates a prompt-ready string with schema and current values:
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
Schema: TripBudget
|
|
206
|
+
Fields:
|
|
207
|
+
- destination (string, min: 1): Destination
|
|
208
|
+
- days (number, min: 1, max: 365): Duration
|
|
209
|
+
- accommodation (object): Accommodation
|
|
210
|
+
|
|
211
|
+
Current Values:
|
|
212
|
+
destination: "Tokyo" (Destination)
|
|
213
|
+
days: 7 (Duration)
|
|
214
|
+
accommodation: {"type":"hotel","budgetPerNight":150} (Accommodation)
|
|
215
|
+
|
|
216
|
+
To edit fields, respond with a JSON Patch array (RFC 6902):
|
|
217
|
+
[{ "op": "replace", "path": "/destination", "value": "Paris" }]
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### `applyJSON(jsonString, source?)`
|
|
221
|
+
|
|
222
|
+
Parses and applies JSON Patch from LLM output:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// Flat field
|
|
226
|
+
const patch1 = '[{ "op": "replace", "path": "/budget", "value": 8000 }]'
|
|
227
|
+
|
|
228
|
+
// Nested field
|
|
229
|
+
const patch2 = '[{ "op": "replace", "path": "/accommodation/type", "value": "airbnb" }]'
|
|
230
|
+
|
|
231
|
+
const result = useStore.llm.applyJSON(patch1)
|
|
232
|
+
// or with source tracking
|
|
233
|
+
const result = useStore.llm.applyJSON(patch1, 'llm')
|
|
234
|
+
|
|
235
|
+
if (result.success) {
|
|
236
|
+
console.log(`Applied ${result.applied} operation(s)`)
|
|
237
|
+
} else {
|
|
238
|
+
console.error(result.error) // validation message
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Accepts both array format `[...]` and wrapped format `{ "patch": [...] }`.
|
|
243
|
+
|
|
244
|
+
### `getFunctionSchema()`
|
|
245
|
+
|
|
246
|
+
Returns OpenAI/Anthropic function calling format:
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
const response = await openai.chat.completions.create({
|
|
250
|
+
model: 'gpt-4',
|
|
251
|
+
messages: [...],
|
|
252
|
+
tools: [{
|
|
253
|
+
type: 'function',
|
|
254
|
+
function: useStore.llm.getFunctionSchema()
|
|
255
|
+
}]
|
|
256
|
+
})
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### `history()`
|
|
260
|
+
|
|
261
|
+
Returns the full audit trail of all patches:
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
const history = useStore.llm.history()
|
|
265
|
+
// [
|
|
266
|
+
// {
|
|
267
|
+
// id: "1",
|
|
268
|
+
// timestamp: 1703782800000,
|
|
269
|
+
// patch: [{ op: "replace", path: "/budget", value: 5000 }],
|
|
270
|
+
// source: "llm",
|
|
271
|
+
// result: { success: true, applied: 1 }
|
|
272
|
+
// }
|
|
273
|
+
// ]
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### `clearHistory()`
|
|
277
|
+
|
|
278
|
+
Clears the patch history.
|
|
279
|
+
|
|
280
|
+
### Other Methods
|
|
281
|
+
|
|
282
|
+
| Method | Description |
|
|
283
|
+
|--------|-------------|
|
|
284
|
+
| `getCompactContext()` | Shorter context for token-constrained prompts |
|
|
285
|
+
| `applyPatch(ops, source?)` | Apply `JsonPatchOp[]` directly (typed) |
|
|
286
|
+
| `getCurrentValues()` | Get current store state |
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Nested Fields
|
|
291
|
+
|
|
292
|
+
duet-kit supports nested objects using JSON Pointer paths:
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
const useStore = createDuet('CRMLead', {
|
|
296
|
+
contact: field(z.object({
|
|
297
|
+
name: z.string(),
|
|
298
|
+
email: z.string().email(),
|
|
299
|
+
phone: z.string(),
|
|
300
|
+
}), 'Contact', { name: '', email: '', phone: '' }),
|
|
301
|
+
|
|
302
|
+
company: field(z.object({
|
|
303
|
+
name: z.string(),
|
|
304
|
+
industry: z.enum(['tech', 'finance', 'healthcare']),
|
|
305
|
+
}), 'Company', { name: '', industry: 'tech' }),
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// LLM can update nested fields directly
|
|
309
|
+
useStore.llm.applyJSON(`[
|
|
310
|
+
{ "op": "replace", "path": "/contact/name", "value": "Sarah Chen" },
|
|
311
|
+
{ "op": "replace", "path": "/contact/email", "value": "sarah@acme.com" },
|
|
312
|
+
{ "op": "replace", "path": "/company/name", "value": "Acme Corp" }
|
|
313
|
+
]`)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Patch Log (Debugging)
|
|
319
|
+
|
|
320
|
+
Every patch is logged for debugging. When an LLM makes unexpected changes or validation fails, you can inspect what happened:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
const history = useStore.llm.history()
|
|
324
|
+
|
|
325
|
+
// [
|
|
326
|
+
// {
|
|
327
|
+
// id: "1",
|
|
328
|
+
// timestamp: 1703782800000,
|
|
329
|
+
// patch: [{ op: "replace", path: "/budget", value: 5000 }],
|
|
330
|
+
// source: "llm",
|
|
331
|
+
// result: { success: true, applied: 1 }
|
|
332
|
+
// },
|
|
333
|
+
// {
|
|
334
|
+
// id: "2",
|
|
335
|
+
// timestamp: 1703782810000,
|
|
336
|
+
// patch: [{ op: "replace", path: "/budget", value: 99999999 }],
|
|
337
|
+
// source: "llm",
|
|
338
|
+
// result: { success: false, error: "Number must be at most 1000000" }
|
|
339
|
+
// }
|
|
340
|
+
// ]
|
|
341
|
+
|
|
342
|
+
// Tag the source for filtering
|
|
343
|
+
useStore.llm.applyPatch([...], 'user') // manual edit
|
|
344
|
+
useStore.llm.applyPatch([...], 'llm') // AI edit (default)
|
|
345
|
+
useStore.llm.applyPatch([...], 'system') // webhook/automation
|
|
346
|
+
|
|
347
|
+
useStore.llm.clearHistory() // reset
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
This is a change log, not a full decision trace. It tells you *what* changed, not *why* (the input, reasoning, or context that led to the change). Useful for debugging and simple undo—not a replacement for proper audit infrastructure.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Use Cases
|
|
355
|
+
|
|
356
|
+
**Configuration wizards**: User sets options, AI suggests related settings, both iterate until "Create" is clicked. Draft state lives in the client until committed.
|
|
357
|
+
|
|
358
|
+
**AI-assisted editors**: Human and AI editing together in real-time—proposals, quotes, documents. Like Google Docs, but one editor is an AI.
|
|
359
|
+
|
|
360
|
+
**Chat-driven UI**: "Change the budget to $5000" → AI parses intent → validated state update → UI reflects instantly.
|
|
361
|
+
|
|
362
|
+
**Agentic workflows**: AI agents that modify your app state autonomously, with validation guardrails and an audit trail of what changed.
|
|
363
|
+
|
|
364
|
+
### When to use duet-kit
|
|
365
|
+
|
|
366
|
+
Use it when human and AI are **editing shared state together in a live session**—especially draft state that doesn't exist in your database yet.
|
|
367
|
+
|
|
368
|
+
If your architecture is "agent updates DB → client syncs later," you probably don't need this. duet-kit is for interactive, client-side state where both editors see changes immediately.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## TypeScript
|
|
373
|
+
|
|
374
|
+
Types are inferred from your schema:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
const useStore = createDuet('User', {
|
|
378
|
+
name: field(z.string(), 'Name', ''),
|
|
379
|
+
age: field(z.number(), 'Age', 0),
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
const { data, set } = useStore()
|
|
383
|
+
data.name // string
|
|
384
|
+
data.age // number
|
|
385
|
+
set('name', 123) // TS error
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Examples
|
|
391
|
+
|
|
392
|
+
See [`examples/`](./examples) for working demos:
|
|
393
|
+
|
|
394
|
+
### Trip Planner
|
|
395
|
+
- Side-by-side human and AI editing the same state
|
|
396
|
+
- Nested fields (accommodation type, budget per night)
|
|
397
|
+
- Debug panel showing `.llm.getContext()`, `.llm.getFunctionSchema()`, `.llm.history()`
|
|
398
|
+
|
|
399
|
+
### CRM Lead Entry
|
|
400
|
+
- AI extracts structured data from natural language input
|
|
401
|
+
- Voice input with Web Speech API (optional)
|
|
402
|
+
- OpenAI integration (optional—works with mock LLM too)
|
|
403
|
+
- Nested contact and company fields with validation
|
|
404
|
+
- Full audit trail of AI changes
|
|
405
|
+
|
|
406
|
+
```bash
|
|
407
|
+
cd examples && npm install && npm run dev
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## Architecture
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
┌──────────────────────────────────────────────────────┐
|
|
416
|
+
│ Your App │
|
|
417
|
+
│ │
|
|
418
|
+
│ React UI LLM / Voice / API │
|
|
419
|
+
│ │ │ │
|
|
420
|
+
│ │ set() │ applyJSON() │
|
|
421
|
+
│ ▼ ▼ │
|
|
422
|
+
│ ┌──────────────────────────────────────────────┐ │
|
|
423
|
+
│ │ useFormStore │ │
|
|
424
|
+
│ │ │ │
|
|
425
|
+
│ │ Zustand Zod LLM Bridge │ │
|
|
426
|
+
│ │ (state) (validation) (context + │ │
|
|
427
|
+
│ │ patch log) │ │
|
|
428
|
+
│ └──────────────────────────────────────────────┘ │
|
|
429
|
+
└──────────────────────────────────────────────────────┘
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Future
|
|
435
|
+
|
|
436
|
+
I'm hoping state management libraries like Zustand offer this natively in the future. Until then, duet-kit fills the gap.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## License
|
|
441
|
+
|
|
442
|
+
MIT
|
package/dist/attach.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* duet-kit - Drop-in LLM Bridge
|
|
3
|
+
*
|
|
4
|
+
* For users who already have Zustand and Zod in their codebase,
|
|
5
|
+
* this allows adding LLM capabilities without rewriting anything.
|
|
6
|
+
* Uses RFC 6902 JSON Patch format.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import type { StoreApi, UseBoundStore } from 'zustand';
|
|
10
|
+
import type { LLMBridge, SchemaFields } from './types';
|
|
11
|
+
type ZodObjectSchema = z.ZodObject<z.ZodRawShape>;
|
|
12
|
+
interface AttachOptions {
|
|
13
|
+
/** Name for the schema (used in LLM context) */
|
|
14
|
+
name?: string;
|
|
15
|
+
/** Human-readable labels for fields */
|
|
16
|
+
labels?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Attach an LLM bridge to an existing Zustand store + Zod schema.
|
|
20
|
+
* Uses RFC 6902 JSON Patch format for edits.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* // Your existing code (unchanged):
|
|
25
|
+
* const todoSchema = z.object({
|
|
26
|
+
* title: z.string(),
|
|
27
|
+
* completed: z.boolean(),
|
|
28
|
+
* priority: z.number().min(1).max(5),
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* const useTodoStore = create<z.infer<typeof todoSchema>>()((set) => ({
|
|
32
|
+
* title: '',
|
|
33
|
+
* completed: false,
|
|
34
|
+
* priority: 1,
|
|
35
|
+
* }));
|
|
36
|
+
*
|
|
37
|
+
* // Drop-in addition (one line):
|
|
38
|
+
* const todoLLM = attachLLM(useTodoStore, todoSchema);
|
|
39
|
+
*
|
|
40
|
+
* // Now you have full LLM capabilities:
|
|
41
|
+
* todoLLM.getContext() // Generate context for prompts
|
|
42
|
+
* todoLLM.applyJSON('...') // Apply JSON Patch
|
|
43
|
+
* todoLLM.getFunctionSchema() // OpenAI function calling
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function attachLLM<T extends ZodObjectSchema>(store: UseBoundStore<StoreApi<z.infer<T>>>, schema: T, options?: AttachOptions): LLMBridge<SchemaFields>;
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=attach.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attach.d.ts","sourceRoot":"","sources":["../src/attach.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACvD,OAAO,KAAK,EAA2B,SAAS,EAAa,YAAY,EAAgB,MAAM,SAAS,CAAC;AAEzG,KAAK,eAAe,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;AAElD,UAAU,aAAa;IACrB,gDAAgD;IAChD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,eAAe,EACjD,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAC1C,MAAM,EAAE,CAAC,EACT,OAAO,GAAE,aAAkB,GAC1B,SAAS,CAAC,YAAY,CAAC,CA8LzB"}
|
package/dist/attach.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* duet-kit - Drop-in LLM Bridge
|
|
3
|
+
*
|
|
4
|
+
* For users who already have Zustand and Zod in their codebase,
|
|
5
|
+
* this allows adding LLM capabilities without rewriting anything.
|
|
6
|
+
* Uses RFC 6902 JSON Patch format.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
/**
|
|
10
|
+
* Attach an LLM bridge to an existing Zustand store + Zod schema.
|
|
11
|
+
* Uses RFC 6902 JSON Patch format for edits.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* // Your existing code (unchanged):
|
|
16
|
+
* const todoSchema = z.object({
|
|
17
|
+
* title: z.string(),
|
|
18
|
+
* completed: z.boolean(),
|
|
19
|
+
* priority: z.number().min(1).max(5),
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* const useTodoStore = create<z.infer<typeof todoSchema>>()((set) => ({
|
|
23
|
+
* title: '',
|
|
24
|
+
* completed: false,
|
|
25
|
+
* priority: 1,
|
|
26
|
+
* }));
|
|
27
|
+
*
|
|
28
|
+
* // Drop-in addition (one line):
|
|
29
|
+
* const todoLLM = attachLLM(useTodoStore, todoSchema);
|
|
30
|
+
*
|
|
31
|
+
* // Now you have full LLM capabilities:
|
|
32
|
+
* todoLLM.getContext() // Generate context for prompts
|
|
33
|
+
* todoLLM.applyJSON('...') // Apply JSON Patch
|
|
34
|
+
* todoLLM.getFunctionSchema() // OpenAI function calling
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function attachLLM(store, schema, options = {}) {
|
|
38
|
+
const name = options.name || 'State';
|
|
39
|
+
const labels = options.labels || {};
|
|
40
|
+
const shape = schema.shape;
|
|
41
|
+
const fieldIds = Object.keys(shape);
|
|
42
|
+
// Validate a single field value
|
|
43
|
+
const validateField = (field, value) => {
|
|
44
|
+
const fieldSchema = shape[field];
|
|
45
|
+
if (!fieldSchema) {
|
|
46
|
+
return { success: false, error: `Unknown field: ${field}` };
|
|
47
|
+
}
|
|
48
|
+
const result = fieldSchema.safeParse(value);
|
|
49
|
+
if (!result.success) {
|
|
50
|
+
return { success: false, error: result.error.errors[0]?.message || 'Invalid value' };
|
|
51
|
+
}
|
|
52
|
+
return { success: true, data: result.data };
|
|
53
|
+
};
|
|
54
|
+
// Get field description from Zod schema
|
|
55
|
+
const getFieldDescription = (fieldId) => {
|
|
56
|
+
const fieldSchema = shape[fieldId];
|
|
57
|
+
const parts = [];
|
|
58
|
+
if (fieldSchema instanceof z.ZodString)
|
|
59
|
+
parts.push('string');
|
|
60
|
+
else if (fieldSchema instanceof z.ZodNumber)
|
|
61
|
+
parts.push('number');
|
|
62
|
+
else if (fieldSchema instanceof z.ZodBoolean)
|
|
63
|
+
parts.push('boolean');
|
|
64
|
+
else if (fieldSchema instanceof z.ZodEnum) {
|
|
65
|
+
const values = fieldSchema._def.values;
|
|
66
|
+
parts.push(`enum: ${values.join(' | ')}`);
|
|
67
|
+
}
|
|
68
|
+
if (fieldSchema instanceof z.ZodNumber) {
|
|
69
|
+
const checks = fieldSchema._def.checks || [];
|
|
70
|
+
for (const check of checks) {
|
|
71
|
+
if (check.kind === 'min')
|
|
72
|
+
parts.push(`min: ${check.value}`);
|
|
73
|
+
if (check.kind === 'max')
|
|
74
|
+
parts.push(`max: ${check.value}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return parts.length > 0 ? `(${parts.join(', ')})` : '';
|
|
78
|
+
};
|
|
79
|
+
// Get default value for a field (current value as fallback)
|
|
80
|
+
const getDefaultValue = (fieldId) => {
|
|
81
|
+
return store.getState()[fieldId];
|
|
82
|
+
};
|
|
83
|
+
// History log for audit trail
|
|
84
|
+
const patchHistory = [];
|
|
85
|
+
let historyId = 0;
|
|
86
|
+
return {
|
|
87
|
+
applyPatch(patch, source = 'llm') {
|
|
88
|
+
const updates = {};
|
|
89
|
+
for (const op of patch) {
|
|
90
|
+
const fieldName = op.path.replace(/^\//, '').split('/')[0];
|
|
91
|
+
if (!fieldIds.includes(fieldName)) {
|
|
92
|
+
const result = { success: false, error: `Unknown field: ${fieldName}` };
|
|
93
|
+
patchHistory.push({ id: String(++historyId), timestamp: Date.now(), patch, source, result });
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
if (op.op === 'remove') {
|
|
97
|
+
updates[fieldName] = getDefaultValue(fieldName);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (op.op === 'replace' || op.op === 'add') {
|
|
101
|
+
const result = validateField(fieldName, op.value);
|
|
102
|
+
if (!result.success) {
|
|
103
|
+
const editResult = { success: false, error: `Invalid value for ${fieldName}: ${result.error}` };
|
|
104
|
+
patchHistory.push({ id: String(++historyId), timestamp: Date.now(), patch, source, result: editResult });
|
|
105
|
+
return editResult;
|
|
106
|
+
}
|
|
107
|
+
updates[fieldName] = result.data;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
store.setState(updates);
|
|
111
|
+
const result = { success: true, applied: patch.length };
|
|
112
|
+
patchHistory.push({ id: String(++historyId), timestamp: Date.now(), patch, source, result });
|
|
113
|
+
return result;
|
|
114
|
+
},
|
|
115
|
+
applyJSON(json, source = 'llm') {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(json);
|
|
118
|
+
if (Array.isArray(parsed)) {
|
|
119
|
+
return this.applyPatch(parsed, source);
|
|
120
|
+
}
|
|
121
|
+
if (parsed.patch && Array.isArray(parsed.patch)) {
|
|
122
|
+
return this.applyPatch(parsed.patch, source);
|
|
123
|
+
}
|
|
124
|
+
return { success: false, error: 'Expected JSON Patch array or { patch: [...] } format' };
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
return { success: false, error: `JSON parse error: ${e.message}` };
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
history() {
|
|
131
|
+
return [...patchHistory];
|
|
132
|
+
},
|
|
133
|
+
clearHistory() {
|
|
134
|
+
patchHistory.length = 0;
|
|
135
|
+
historyId = 0;
|
|
136
|
+
},
|
|
137
|
+
getContext() {
|
|
138
|
+
const data = store.getState();
|
|
139
|
+
let context = `${name} Schema:\n`;
|
|
140
|
+
for (const id of fieldIds) {
|
|
141
|
+
const label = labels[id] || id;
|
|
142
|
+
const desc = getFieldDescription(id);
|
|
143
|
+
context += ` - ${id}: ${label} ${desc}\n`;
|
|
144
|
+
}
|
|
145
|
+
context += '\nCurrent Values:\n';
|
|
146
|
+
for (const id of fieldIds) {
|
|
147
|
+
const value = data[id];
|
|
148
|
+
const label = labels[id] || id;
|
|
149
|
+
context += ` ${id}: ${JSON.stringify(value)} (${label})\n`;
|
|
150
|
+
}
|
|
151
|
+
context += `
|
|
152
|
+
To edit fields, respond with a JSON Patch array (RFC 6902):
|
|
153
|
+
[{ "op": "replace", "path": "/fieldName", "value": newValue }]
|
|
154
|
+
|
|
155
|
+
Examples:
|
|
156
|
+
- Single edit: [{ "op": "replace", "path": "/${fieldIds[0] || 'field'}", "value": "newValue" }]
|
|
157
|
+
- Multiple: [{ "op": "replace", "path": "/a", "value": 1 }, { "op": "replace", "path": "/b", "value": 2 }]`;
|
|
158
|
+
return context;
|
|
159
|
+
},
|
|
160
|
+
getCompactContext() {
|
|
161
|
+
const data = store.getState();
|
|
162
|
+
const values = Object.entries(data)
|
|
163
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
164
|
+
.join(', ');
|
|
165
|
+
return `${name}: {${values}}\nJSON Patch: [{"op":"replace","path":"/field","value":x}]`;
|
|
166
|
+
},
|
|
167
|
+
getFunctionSchema() {
|
|
168
|
+
return {
|
|
169
|
+
name: `patch_${name.toLowerCase().replace(/\s+/g, '_')}`,
|
|
170
|
+
description: `Apply JSON Patch operations to ${name}. Uses RFC 6902 format.`,
|
|
171
|
+
parameters: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: {
|
|
174
|
+
patch: {
|
|
175
|
+
type: 'array',
|
|
176
|
+
description: 'JSON Patch operations (RFC 6902)',
|
|
177
|
+
items: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
op: {
|
|
181
|
+
type: 'string',
|
|
182
|
+
enum: ['replace', 'add', 'remove'],
|
|
183
|
+
description: 'Operation type',
|
|
184
|
+
},
|
|
185
|
+
path: {
|
|
186
|
+
type: 'string',
|
|
187
|
+
description: `JSON Pointer to field. Valid paths: ${fieldIds.map(f => `/${f}`).join(', ')}`,
|
|
188
|
+
},
|
|
189
|
+
value: {
|
|
190
|
+
description: 'New value (required for replace/add)',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
required: ['op', 'path'],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
required: ['patch'],
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
getCurrentValues() {
|
|
202
|
+
return store.getState();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|