@agentuity/runtime 0.0.43 → 0.0.45
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/AGENTS.md +11 -9
- package/README.md +4 -4
- package/dist/_context.d.ts +12 -4
- package/dist/_context.d.ts.map +1 -1
- package/dist/_server.d.ts +7 -4
- package/dist/_server.d.ts.map +1 -1
- package/dist/_services.d.ts +13 -2
- package/dist/_services.d.ts.map +1 -1
- package/dist/_util.d.ts +1 -1
- package/dist/_util.d.ts.map +1 -1
- package/dist/_waituntil.d.ts +1 -3
- package/dist/_waituntil.d.ts.map +1 -1
- package/dist/agent.d.ts +41 -14
- package/dist/agent.d.ts.map +1 -1
- package/dist/app.d.ts +90 -8
- package/dist/app.d.ts.map +1 -1
- package/dist/eval.d.ts +79 -0
- package/dist/eval.d.ts.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/io/email.d.ts +77 -0
- package/dist/io/email.d.ts.map +1 -0
- package/dist/logger/console.d.ts +7 -1
- package/dist/logger/console.d.ts.map +1 -1
- package/dist/logger/user.d.ts.map +1 -1
- package/dist/otel/config.d.ts +3 -1
- package/dist/otel/config.d.ts.map +1 -1
- package/dist/otel/console.d.ts +2 -1
- package/dist/otel/console.d.ts.map +1 -1
- package/dist/otel/exporters/index.d.ts +4 -0
- package/dist/otel/exporters/index.d.ts.map +1 -0
- package/dist/otel/exporters/jsonl-log-exporter.d.ts +36 -0
- package/dist/otel/exporters/jsonl-log-exporter.d.ts.map +1 -0
- package/dist/otel/exporters/jsonl-metric-exporter.d.ts +40 -0
- package/dist/otel/exporters/jsonl-metric-exporter.d.ts.map +1 -0
- package/dist/otel/exporters/jsonl-trace-exporter.d.ts +36 -0
- package/dist/otel/exporters/jsonl-trace-exporter.d.ts.map +1 -0
- package/dist/otel/http.d.ts.map +1 -1
- package/dist/otel/logger.d.ts +8 -6
- package/dist/otel/logger.d.ts.map +1 -1
- package/dist/otel/otel.d.ts +8 -2
- package/dist/otel/otel.d.ts.map +1 -1
- package/dist/router.d.ts +4 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/services/evalrun/composite.d.ts +21 -0
- package/dist/services/evalrun/composite.d.ts.map +1 -0
- package/dist/services/evalrun/http.d.ts +24 -0
- package/dist/services/evalrun/http.d.ts.map +1 -0
- package/dist/services/evalrun/index.d.ts +5 -0
- package/dist/services/evalrun/index.d.ts.map +1 -0
- package/dist/services/evalrun/json.d.ts +21 -0
- package/dist/services/evalrun/json.d.ts.map +1 -0
- package/dist/services/evalrun/local.d.ts +19 -0
- package/dist/services/evalrun/local.d.ts.map +1 -0
- package/dist/services/local/_db.d.ts +4 -0
- package/dist/services/local/_db.d.ts.map +1 -0
- package/dist/services/local/_router.d.ts +3 -0
- package/dist/services/local/_router.d.ts.map +1 -0
- package/dist/services/local/_util.d.ts +18 -0
- package/dist/services/local/_util.d.ts.map +1 -0
- package/dist/services/local/index.d.ts +8 -0
- package/dist/services/local/index.d.ts.map +1 -0
- package/dist/services/local/keyvalue.d.ts +10 -0
- package/dist/services/local/keyvalue.d.ts.map +1 -0
- package/dist/services/local/objectstore.d.ts +11 -0
- package/dist/services/local/objectstore.d.ts.map +1 -0
- package/dist/services/local/stream.d.ts +10 -0
- package/dist/services/local/stream.d.ts.map +1 -0
- package/dist/services/local/vector.d.ts +13 -0
- package/dist/services/local/vector.d.ts.map +1 -0
- package/dist/services/session/composite.d.ts +21 -0
- package/dist/services/session/composite.d.ts.map +1 -0
- package/dist/services/session/http.d.ts +23 -0
- package/dist/services/session/http.d.ts.map +1 -0
- package/dist/services/session/index.d.ts +5 -0
- package/dist/services/session/index.d.ts.map +1 -0
- package/dist/services/session/json.d.ts +22 -0
- package/dist/services/session/json.d.ts.map +1 -0
- package/dist/services/session/local.d.ts +19 -0
- package/dist/services/session/local.d.ts.map +1 -0
- package/dist/session.d.ts +70 -0
- package/dist/session.d.ts.map +1 -0
- package/package.json +10 -6
- package/src/_config.ts +1 -1
- package/src/_context.ts +19 -16
- package/src/_server.ts +284 -42
- package/src/_services.ts +147 -34
- package/src/_util.ts +2 -3
- package/src/_waituntil.ts +5 -153
- package/src/agent.ts +667 -65
- package/src/app.ts +159 -13
- package/src/eval.ts +95 -0
- package/src/index.ts +6 -1
- package/src/io/email.ts +173 -0
- package/src/logger/console.ts +196 -17
- package/src/logger/user.ts +7 -3
- package/src/otel/config.ts +7 -44
- package/src/otel/console.ts +8 -4
- package/src/otel/exporters/README.md +217 -0
- package/src/otel/exporters/index.ts +3 -0
- package/src/otel/exporters/jsonl-log-exporter.ts +113 -0
- package/src/otel/exporters/jsonl-metric-exporter.ts +120 -0
- package/src/otel/exporters/jsonl-trace-exporter.ts +121 -0
- package/src/otel/http.ts +3 -1
- package/src/otel/logger.ts +87 -37
- package/src/otel/otel.ts +43 -22
- package/src/router.ts +44 -4
- package/src/services/evalrun/composite.ts +34 -0
- package/src/services/evalrun/http.ts +112 -0
- package/src/services/evalrun/index.ts +4 -0
- package/src/services/evalrun/json.ts +46 -0
- package/src/services/evalrun/local.ts +28 -0
- package/src/services/local/README.md +1576 -0
- package/src/services/local/_db.ts +182 -0
- package/src/services/local/_router.ts +86 -0
- package/src/services/local/_util.ts +49 -0
- package/src/services/local/index.ts +7 -0
- package/src/services/local/keyvalue.ts +118 -0
- package/src/services/local/objectstore.ts +152 -0
- package/src/services/local/stream.ts +296 -0
- package/src/services/local/vector.ts +264 -0
- package/src/services/session/composite.ts +33 -0
- package/src/services/session/http.ts +64 -0
- package/src/services/session/index.ts +4 -0
- package/src/services/session/json.ts +42 -0
- package/src/services/session/local.ts +28 -0
- package/src/session.ts +284 -0
- package/dist/_unauthenticated.d.ts +0 -26
- package/dist/_unauthenticated.d.ts.map +0 -1
- package/src/_unauthenticated.ts +0 -126
|
@@ -0,0 +1,1576 @@
|
|
|
1
|
+
# Local SQLite Services Implementation Plan
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Implement local SQLite-backed storage services for development and testing without requiring authentication or external service dependencies.
|
|
6
|
+
|
|
7
|
+
### Goals
|
|
8
|
+
|
|
9
|
+
- Provide fully functional local implementations of all 4 storage service interfaces
|
|
10
|
+
- Use Bun's built-in SQLite for storage
|
|
11
|
+
- Support multi-project data partitioning by normalized directory path
|
|
12
|
+
- Enable serving objects/streams via local HTTP endpoints
|
|
13
|
+
- Replace current unauthenticated error-throwing services in unauth-app
|
|
14
|
+
|
|
15
|
+
### Non-Goals
|
|
16
|
+
|
|
17
|
+
- Production performance optimization (acceptable for local dev only)
|
|
18
|
+
- Distributed/multi-process access
|
|
19
|
+
- Data persistence guarantees beyond SQLite durability
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Architecture
|
|
24
|
+
|
|
25
|
+
### Database Location
|
|
26
|
+
|
|
27
|
+
- **Path**: `$HOME/.config/agentuity/local.db`
|
|
28
|
+
- **Driver**: Bun's built-in SQLite (`bun:sqlite`)
|
|
29
|
+
- **Connection**: Singleton pattern to avoid multiple opens
|
|
30
|
+
- **Initialization**: Create directory and DB file if not exists
|
|
31
|
+
- **Auto-cleanup**: On startup, orphaned project data is automatically removed (projects whose directories no longer exist)
|
|
32
|
+
|
|
33
|
+
### Project Partitioning
|
|
34
|
+
|
|
35
|
+
All tables include a `project_path` column storing the **normalized absolute path** of the project directory. This allows:
|
|
36
|
+
|
|
37
|
+
- Multiple projects to share the same database
|
|
38
|
+
- Easy querying/filtering by project
|
|
39
|
+
- Data isolation between projects
|
|
40
|
+
|
|
41
|
+
### URL Generation
|
|
42
|
+
|
|
43
|
+
For `ObjectStorage.createPublicURL()` and stream URLs:
|
|
44
|
+
|
|
45
|
+
- Serve via local Hono routes mounted on the main app
|
|
46
|
+
- Pattern: `http://localhost:{port}/_agentuity/local/object/{bucket}/{key}`
|
|
47
|
+
- Pattern: `http://localhost:{port}/_agentuity/local/stream/{id}`
|
|
48
|
+
- Only available when running with local services enabled
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## File Structure
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
packages/runtime/src/services/local/
|
|
56
|
+
├── index.ts # Public exports
|
|
57
|
+
├── _db.ts # Singleton DB connection & schema initialization
|
|
58
|
+
├── _util.ts # Shared utilities (path normalization, embeddings)
|
|
59
|
+
├── _router.ts # Hono router for serving objects and streams
|
|
60
|
+
├── keyvalue.ts # LocalKeyValueStorage implementation
|
|
61
|
+
├── objectstore.ts # LocalObjectStorage implementation
|
|
62
|
+
├── stream.ts # LocalStreamStorage implementation
|
|
63
|
+
└── vector.ts # LocalVectorStorage implementation
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Database Schema
|
|
69
|
+
|
|
70
|
+
### Table: `kv_storage`
|
|
71
|
+
|
|
72
|
+
```sql
|
|
73
|
+
CREATE TABLE IF NOT EXISTS kv_storage (
|
|
74
|
+
project_path TEXT NOT NULL,
|
|
75
|
+
name TEXT NOT NULL,
|
|
76
|
+
key TEXT NOT NULL,
|
|
77
|
+
value BLOB NOT NULL,
|
|
78
|
+
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
79
|
+
expires_at INTEGER, -- Unix timestamp in milliseconds, NULL = no expiration
|
|
80
|
+
created_at INTEGER NOT NULL,
|
|
81
|
+
updated_at INTEGER NOT NULL,
|
|
82
|
+
PRIMARY KEY (project_path, name, key)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_kv_expires
|
|
86
|
+
ON kv_storage(expires_at)
|
|
87
|
+
WHERE expires_at IS NOT NULL;
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Notes**:
|
|
91
|
+
|
|
92
|
+
- `value` stored as BLOB (supports any binary data)
|
|
93
|
+
- `expires_at` checked on read, expired entries return `exists: false`
|
|
94
|
+
- Optional: Background cleanup job to DELETE expired rows
|
|
95
|
+
|
|
96
|
+
### Table: `object_storage`
|
|
97
|
+
|
|
98
|
+
```sql
|
|
99
|
+
CREATE TABLE IF NOT EXISTS object_storage (
|
|
100
|
+
project_path TEXT NOT NULL,
|
|
101
|
+
bucket TEXT NOT NULL,
|
|
102
|
+
key TEXT NOT NULL,
|
|
103
|
+
data BLOB NOT NULL,
|
|
104
|
+
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
105
|
+
content_encoding TEXT,
|
|
106
|
+
cache_control TEXT,
|
|
107
|
+
content_disposition TEXT,
|
|
108
|
+
content_language TEXT,
|
|
109
|
+
metadata TEXT, -- JSON string of Record<string, string>
|
|
110
|
+
created_at INTEGER NOT NULL,
|
|
111
|
+
updated_at INTEGER NOT NULL,
|
|
112
|
+
PRIMARY KEY (project_path, bucket, key)
|
|
113
|
+
);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Notes**:
|
|
117
|
+
|
|
118
|
+
- `metadata` stored as JSON string, parsed on retrieval
|
|
119
|
+
- All HTTP headers preserved for accurate `get()` responses
|
|
120
|
+
|
|
121
|
+
### Table: `stream_storage`
|
|
122
|
+
|
|
123
|
+
```sql
|
|
124
|
+
CREATE TABLE IF NOT EXISTS stream_storage (
|
|
125
|
+
project_path TEXT NOT NULL,
|
|
126
|
+
id TEXT PRIMARY KEY, -- UUID
|
|
127
|
+
name TEXT NOT NULL,
|
|
128
|
+
metadata TEXT, -- JSON string
|
|
129
|
+
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
130
|
+
data BLOB, -- NULL until stream is closed
|
|
131
|
+
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
132
|
+
created_at INTEGER NOT NULL
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
CREATE INDEX IF NOT EXISTS idx_stream_name
|
|
136
|
+
ON stream_storage(project_path, name);
|
|
137
|
+
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_stream_metadata
|
|
139
|
+
ON stream_storage(metadata);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Notes**:
|
|
143
|
+
|
|
144
|
+
- Stream is created with `data = NULL`
|
|
145
|
+
- Data buffered in memory during writes
|
|
146
|
+
- On `close()`, data persisted to BLOB
|
|
147
|
+
- `list()` supports filtering by name and metadata (JSON queries)
|
|
148
|
+
|
|
149
|
+
### Table: `vector_storage`
|
|
150
|
+
|
|
151
|
+
```sql
|
|
152
|
+
CREATE TABLE IF NOT EXISTS vector_storage (
|
|
153
|
+
project_path TEXT NOT NULL,
|
|
154
|
+
name TEXT NOT NULL,
|
|
155
|
+
id TEXT PRIMARY KEY, -- UUID
|
|
156
|
+
key TEXT NOT NULL,
|
|
157
|
+
embedding TEXT NOT NULL, -- JSON array of numbers
|
|
158
|
+
document TEXT, -- Original text used for embedding (optional)
|
|
159
|
+
metadata TEXT, -- JSON object
|
|
160
|
+
created_at INTEGER NOT NULL,
|
|
161
|
+
updated_at INTEGER NOT NULL,
|
|
162
|
+
UNIQUE (project_path, name, key)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_vector_lookup
|
|
166
|
+
ON vector_storage(project_path, name, key);
|
|
167
|
+
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_vector_name
|
|
169
|
+
ON vector_storage(project_path, name);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Notes**:
|
|
173
|
+
|
|
174
|
+
- `embedding` stored as JSON array for simplicity
|
|
175
|
+
- `document` preserved for retrieval (matches API)
|
|
176
|
+
- `search()` does full table scan with in-memory similarity calc (acceptable for local dev)
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Implementation Details
|
|
181
|
+
|
|
182
|
+
### 1. Database Infrastructure (`_db.ts`)
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
import { Database } from 'bun:sqlite';
|
|
186
|
+
import { mkdirSync, existsSync } from 'node:fs';
|
|
187
|
+
import { homedir } from 'node:os';
|
|
188
|
+
import { join } from 'node:path';
|
|
189
|
+
|
|
190
|
+
let dbInstance: Database | null = null;
|
|
191
|
+
|
|
192
|
+
export function getLocalDB(): Database {
|
|
193
|
+
if (dbInstance) {
|
|
194
|
+
return dbInstance;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const configDir = join(homedir(), '.config', 'agentuity');
|
|
198
|
+
|
|
199
|
+
if (!existsSync(configDir)) {
|
|
200
|
+
mkdirSync(configDir, { recursive: true });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const dbPath = join(configDir, 'local.db');
|
|
204
|
+
dbInstance = new Database(dbPath);
|
|
205
|
+
|
|
206
|
+
initializeTables(dbInstance);
|
|
207
|
+
|
|
208
|
+
return dbInstance;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function initializeTables(db: Database): void {
|
|
212
|
+
// Create all 4 tables with schemas defined above
|
|
213
|
+
// Execute CREATE TABLE IF NOT EXISTS statements
|
|
214
|
+
// Execute CREATE INDEX IF NOT EXISTS statements
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function cleanupOrphanedProjects(db: Database): void {
|
|
218
|
+
// Get the current project path to exclude from cleanup
|
|
219
|
+
const currentProjectPath = process.cwd();
|
|
220
|
+
|
|
221
|
+
// Query all tables for unique project paths
|
|
222
|
+
// Combine and deduplicate all project paths
|
|
223
|
+
// Check which paths no longer exist and are not the current project
|
|
224
|
+
// Delete data for removed projects from all tables
|
|
225
|
+
|
|
226
|
+
// Logs: "[LocalDB] Cleaned up data for N orphaned project(s)"
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function closeLocalDB(): void {
|
|
230
|
+
if (dbInstance) {
|
|
231
|
+
dbInstance.close();
|
|
232
|
+
dbInstance = null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Responsibilities**:
|
|
238
|
+
|
|
239
|
+
- Singleton pattern for DB connection
|
|
240
|
+
- Create config directory if missing
|
|
241
|
+
- Initialize all tables and indexes
|
|
242
|
+
- Provide cleanup function for tests
|
|
243
|
+
|
|
244
|
+
### 2. Shared Utilities (`_util.ts`)
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import { resolve } from 'node:path';
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Normalize a project path to an absolute path for consistent DB keys
|
|
251
|
+
*/
|
|
252
|
+
export function normalizeProjectPath(cwd: string = process.cwd()): string {
|
|
253
|
+
return resolve(cwd);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Simple character-based embedding for local vector search
|
|
258
|
+
* Not production-quality, but good enough for local dev/testing
|
|
259
|
+
*/
|
|
260
|
+
export function simpleEmbedding(text: string, dimensions = 128): number[] {
|
|
261
|
+
const vec = new Array(dimensions).fill(0);
|
|
262
|
+
const normalized = text.toLowerCase();
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
265
|
+
const charCode = normalized.charCodeAt(i);
|
|
266
|
+
vec[i % dimensions] += Math.sin(charCode * (i + 1));
|
|
267
|
+
vec[(i * 2) % dimensions] += Math.cos(charCode);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Normalize vector
|
|
271
|
+
const magnitude = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
|
|
272
|
+
return magnitude > 0 ? vec.map((v) => v / magnitude) : vec;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Calculate cosine similarity between two vectors
|
|
277
|
+
*/
|
|
278
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
279
|
+
if (a.length !== b.length) {
|
|
280
|
+
throw new Error('Vectors must have the same dimension');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const dot = a.reduce((sum, ai, i) => sum + ai * b[i], 0);
|
|
284
|
+
const normA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0));
|
|
285
|
+
const normB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0));
|
|
286
|
+
|
|
287
|
+
return normA > 0 && normB > 0 ? dot / (normA * normB) : 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Get current timestamp in milliseconds
|
|
292
|
+
*/
|
|
293
|
+
export function now(): number {
|
|
294
|
+
return Date.now();
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### 3. KeyValue Storage (`keyvalue.ts`)
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
import type { Database } from 'bun:sqlite';
|
|
302
|
+
import type { KeyValueStorage, DataResult, KeyValueStorageSetParams } from '@agentuity/core';
|
|
303
|
+
import { now } from './_util';
|
|
304
|
+
|
|
305
|
+
export class LocalKeyValueStorage implements KeyValueStorage {
|
|
306
|
+
#db: Database;
|
|
307
|
+
#projectPath: string;
|
|
308
|
+
|
|
309
|
+
constructor(db: Database, projectPath: string) {
|
|
310
|
+
this.#db = db;
|
|
311
|
+
this.#projectPath = projectPath;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async get<T>(name: string, key: string): Promise<DataResult<T>> {
|
|
315
|
+
const query = this.#db.query(`
|
|
316
|
+
SELECT value, content_type, expires_at
|
|
317
|
+
FROM kv_storage
|
|
318
|
+
WHERE project_path = ? AND name = ? AND key = ?
|
|
319
|
+
`);
|
|
320
|
+
|
|
321
|
+
const row = query.get(this.#projectPath, name, key) as {
|
|
322
|
+
value: Buffer;
|
|
323
|
+
content_type: string;
|
|
324
|
+
expires_at: number | null;
|
|
325
|
+
} | null;
|
|
326
|
+
|
|
327
|
+
if (!row) {
|
|
328
|
+
return { exists: false } as DataResultNotFound;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Check expiration
|
|
332
|
+
if (row.expires_at && row.expires_at < now()) {
|
|
333
|
+
// Optionally delete expired row
|
|
334
|
+
this.delete(name, key);
|
|
335
|
+
return { exists: false } as DataResultNotFound;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Deserialize based on content type
|
|
339
|
+
let data: T;
|
|
340
|
+
if (row.content_type === 'application/json') {
|
|
341
|
+
data = JSON.parse(row.value.toString('utf-8'));
|
|
342
|
+
} else if (row.content_type.startsWith('text/')) {
|
|
343
|
+
data = row.value.toString('utf-8') as T;
|
|
344
|
+
} else {
|
|
345
|
+
data = new Uint8Array(row.value) as T;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
data,
|
|
350
|
+
contentType: row.content_type,
|
|
351
|
+
exists: true,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async set<T = unknown>(
|
|
356
|
+
name: string,
|
|
357
|
+
key: string,
|
|
358
|
+
value: T,
|
|
359
|
+
params?: KeyValueStorageSetParams
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
// Validate TTL
|
|
362
|
+
if (params?.ttl && params.ttl < 60) {
|
|
363
|
+
throw new Error(`ttl must be at least 60 seconds, got ${params.ttl}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Serialize value
|
|
367
|
+
let buffer: Buffer;
|
|
368
|
+
let contentType = params?.contentType || 'application/octet-stream';
|
|
369
|
+
|
|
370
|
+
if (typeof value === 'string') {
|
|
371
|
+
buffer = Buffer.from(value, 'utf-8');
|
|
372
|
+
if (!params?.contentType) {
|
|
373
|
+
contentType = 'text/plain';
|
|
374
|
+
}
|
|
375
|
+
} else if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
|
|
376
|
+
buffer = Buffer.from(value);
|
|
377
|
+
} else if (typeof value === 'object') {
|
|
378
|
+
buffer = Buffer.from(JSON.stringify(value), 'utf-8');
|
|
379
|
+
contentType = 'application/json';
|
|
380
|
+
} else {
|
|
381
|
+
buffer = Buffer.from(String(value), 'utf-8');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Calculate expiration
|
|
385
|
+
const expiresAt = params?.ttl ? now() + params.ttl * 1000 : null;
|
|
386
|
+
const timestamp = now();
|
|
387
|
+
|
|
388
|
+
// UPSERT
|
|
389
|
+
const stmt = this.#db.prepare(`
|
|
390
|
+
INSERT INTO kv_storage (project_path, name, key, value, content_type, expires_at, created_at, updated_at)
|
|
391
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
392
|
+
ON CONFLICT(project_path, name, key)
|
|
393
|
+
DO UPDATE SET
|
|
394
|
+
value = excluded.value,
|
|
395
|
+
content_type = excluded.content_type,
|
|
396
|
+
expires_at = excluded.expires_at,
|
|
397
|
+
updated_at = excluded.updated_at
|
|
398
|
+
`);
|
|
399
|
+
|
|
400
|
+
stmt.run(this.#projectPath, name, key, buffer, contentType, expiresAt, timestamp, timestamp);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async delete(name: string, key: string): Promise<void> {
|
|
404
|
+
const stmt = this.#db.prepare(`
|
|
405
|
+
DELETE FROM kv_storage
|
|
406
|
+
WHERE project_path = ? AND name = ? AND key = ?
|
|
407
|
+
`);
|
|
408
|
+
|
|
409
|
+
stmt.run(this.#projectPath, name, key);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
**Key Features**:
|
|
415
|
+
|
|
416
|
+
- TTL validation (≥60 seconds)
|
|
417
|
+
- Automatic expiration checking on `get()`
|
|
418
|
+
- Content-type aware serialization/deserialization
|
|
419
|
+
- UPSERT pattern for `set()`
|
|
420
|
+
|
|
421
|
+
### 4. Object Storage (`objectstore.ts`)
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
import type { Database } from 'bun:sqlite';
|
|
425
|
+
import type {
|
|
426
|
+
ObjectStorage,
|
|
427
|
+
ObjectResult,
|
|
428
|
+
ObjectStorePutParams,
|
|
429
|
+
CreatePublicURLParams,
|
|
430
|
+
} from '@agentuity/core';
|
|
431
|
+
import { now } from './_util';
|
|
432
|
+
|
|
433
|
+
export class LocalObjectStorage implements ObjectStorage {
|
|
434
|
+
#db: Database;
|
|
435
|
+
#projectPath: string;
|
|
436
|
+
#serverUrl: string;
|
|
437
|
+
|
|
438
|
+
constructor(db: Database, projectPath: string, serverUrl: string) {
|
|
439
|
+
this.#db = db;
|
|
440
|
+
this.#projectPath = projectPath;
|
|
441
|
+
this.#serverUrl = serverUrl;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async get(bucket: string, key: string): Promise<ObjectResult> {
|
|
445
|
+
if (!bucket?.trim() || !key?.trim()) {
|
|
446
|
+
throw new Error('bucket and key are required');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const query = this.#db.query(`
|
|
450
|
+
SELECT data, content_type
|
|
451
|
+
FROM object_storage
|
|
452
|
+
WHERE project_path = ? AND bucket = ? AND key = ?
|
|
453
|
+
`);
|
|
454
|
+
|
|
455
|
+
const row = query.get(this.#projectPath, bucket, key) as {
|
|
456
|
+
data: Buffer;
|
|
457
|
+
content_type: string;
|
|
458
|
+
} | null;
|
|
459
|
+
|
|
460
|
+
if (!row) {
|
|
461
|
+
return { exists: false } as ObjectResultNotFound;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
exists: true,
|
|
466
|
+
data: new Uint8Array(row.data),
|
|
467
|
+
contentType: row.content_type,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async put(
|
|
472
|
+
bucket: string,
|
|
473
|
+
key: string,
|
|
474
|
+
data: Uint8Array | ArrayBuffer | ReadableStream,
|
|
475
|
+
params?: ObjectStorePutParams
|
|
476
|
+
): Promise<void> {
|
|
477
|
+
if (!bucket?.trim() || !key?.trim()) {
|
|
478
|
+
throw new Error('bucket and key are required');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Convert data to Buffer
|
|
482
|
+
let buffer: Buffer;
|
|
483
|
+
if (data instanceof ReadableStream) {
|
|
484
|
+
// Read entire stream into buffer
|
|
485
|
+
const reader = data.getReader();
|
|
486
|
+
const chunks: Uint8Array[] = [];
|
|
487
|
+
while (true) {
|
|
488
|
+
const { done, value } = await reader.read();
|
|
489
|
+
if (done) break;
|
|
490
|
+
chunks.push(value);
|
|
491
|
+
}
|
|
492
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
493
|
+
buffer = Buffer.concat(
|
|
494
|
+
chunks.map((c) => Buffer.from(c)),
|
|
495
|
+
totalLength
|
|
496
|
+
);
|
|
497
|
+
} else if (data instanceof ArrayBuffer) {
|
|
498
|
+
buffer = Buffer.from(data);
|
|
499
|
+
} else {
|
|
500
|
+
buffer = Buffer.from(data);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const timestamp = now();
|
|
504
|
+
const metadata = params?.metadata ? JSON.stringify(params.metadata) : null;
|
|
505
|
+
|
|
506
|
+
const stmt = this.#db.prepare(`
|
|
507
|
+
INSERT INTO object_storage (
|
|
508
|
+
project_path, bucket, key, data, content_type,
|
|
509
|
+
content_encoding, cache_control, content_disposition,
|
|
510
|
+
content_language, metadata, created_at, updated_at
|
|
511
|
+
)
|
|
512
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
513
|
+
ON CONFLICT(project_path, bucket, key)
|
|
514
|
+
DO UPDATE SET
|
|
515
|
+
data = excluded.data,
|
|
516
|
+
content_type = excluded.content_type,
|
|
517
|
+
content_encoding = excluded.content_encoding,
|
|
518
|
+
cache_control = excluded.cache_control,
|
|
519
|
+
content_disposition = excluded.content_disposition,
|
|
520
|
+
content_language = excluded.content_language,
|
|
521
|
+
metadata = excluded.metadata,
|
|
522
|
+
updated_at = excluded.updated_at
|
|
523
|
+
`);
|
|
524
|
+
|
|
525
|
+
stmt.run(
|
|
526
|
+
this.#projectPath,
|
|
527
|
+
bucket,
|
|
528
|
+
key,
|
|
529
|
+
buffer,
|
|
530
|
+
params?.contentType || 'application/octet-stream',
|
|
531
|
+
params?.contentEncoding || null,
|
|
532
|
+
params?.cacheControl || null,
|
|
533
|
+
params?.contentDisposition || null,
|
|
534
|
+
params?.contentLanguage || null,
|
|
535
|
+
metadata,
|
|
536
|
+
timestamp,
|
|
537
|
+
timestamp
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async delete(bucket: string, key: string): Promise<boolean> {
|
|
542
|
+
if (!bucket?.trim() || !key?.trim()) {
|
|
543
|
+
throw new Error('bucket and key are required');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const stmt = this.#db.prepare(`
|
|
547
|
+
DELETE FROM object_storage
|
|
548
|
+
WHERE project_path = ? AND bucket = ? AND key = ?
|
|
549
|
+
`);
|
|
550
|
+
|
|
551
|
+
const result = stmt.run(this.#projectPath, bucket, key);
|
|
552
|
+
return result.changes > 0;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async createPublicURL(
|
|
556
|
+
bucket: string,
|
|
557
|
+
key: string,
|
|
558
|
+
_params?: CreatePublicURLParams
|
|
559
|
+
): Promise<string> {
|
|
560
|
+
if (!bucket?.trim() || !key?.trim()) {
|
|
561
|
+
throw new Error('bucket and key are required');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Verify object exists
|
|
565
|
+
const result = await this.get(bucket, key);
|
|
566
|
+
if (!result.exists) {
|
|
567
|
+
throw new Error('Object not found');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Return local HTTP URL
|
|
571
|
+
// Note: params.expiresDuration is ignored for local implementation
|
|
572
|
+
return `${this.#serverUrl}/_agentuity/local/object/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
**Key Features**:
|
|
578
|
+
|
|
579
|
+
- ReadableStream support (reads entire stream into memory)
|
|
580
|
+
- Metadata stored as JSON
|
|
581
|
+
- Public URL generation pointing to local HTTP endpoint
|
|
582
|
+
- `delete()` returns true/false based on whether row was deleted
|
|
583
|
+
|
|
584
|
+
### 5. Stream Storage (`stream.ts`)
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
import type { Database } from 'bun:sqlite';
|
|
588
|
+
import type {
|
|
589
|
+
StreamStorage,
|
|
590
|
+
Stream,
|
|
591
|
+
CreateStreamProps,
|
|
592
|
+
ListStreamsParams,
|
|
593
|
+
ListStreamsResponse,
|
|
594
|
+
StreamInfo,
|
|
595
|
+
} from '@agentuity/core';
|
|
596
|
+
import { now } from './_util';
|
|
597
|
+
import { join } from 'node:path';
|
|
598
|
+
import { homedir } from 'node:os';
|
|
599
|
+
import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
|
600
|
+
import { openSync, writeSync, closeSync, readFileSync } from 'node:fs';
|
|
601
|
+
|
|
602
|
+
export class LocalStreamStorage implements StreamStorage {
|
|
603
|
+
#db: Database;
|
|
604
|
+
#projectPath: string;
|
|
605
|
+
#serverUrl: string;
|
|
606
|
+
#tempDir: string;
|
|
607
|
+
|
|
608
|
+
constructor(db: Database, projectPath: string, serverUrl: string) {
|
|
609
|
+
this.#db = db;
|
|
610
|
+
this.#projectPath = projectPath;
|
|
611
|
+
this.#serverUrl = serverUrl;
|
|
612
|
+
|
|
613
|
+
// Create temp directory for stream buffering
|
|
614
|
+
this.#tempDir = join(homedir(), '.config', 'agentuity', 'streams');
|
|
615
|
+
if (!existsSync(this.#tempDir)) {
|
|
616
|
+
mkdirSync(this.#tempDir, { recursive: true });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async create(name: string, props?: CreateStreamProps): Promise<Stream> {
|
|
621
|
+
if (!name || name.length < 1 || name.length > 254) {
|
|
622
|
+
throw new Error('Stream name must be between 1 and 254 characters');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const id = crypto.randomUUID();
|
|
626
|
+
const timestamp = now();
|
|
627
|
+
const metadata = props?.metadata ? JSON.stringify(props.metadata) : null;
|
|
628
|
+
|
|
629
|
+
// Insert stream record with NULL data
|
|
630
|
+
const stmt = this.#db.prepare(`
|
|
631
|
+
INSERT INTO stream_storage (
|
|
632
|
+
project_path, id, name, metadata, content_type, created_at
|
|
633
|
+
)
|
|
634
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
635
|
+
`);
|
|
636
|
+
|
|
637
|
+
stmt.run(
|
|
638
|
+
this.#projectPath,
|
|
639
|
+
id,
|
|
640
|
+
name,
|
|
641
|
+
metadata,
|
|
642
|
+
props?.contentType || 'application/octet-stream',
|
|
643
|
+
timestamp
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
const url = `${this.#serverUrl}/_agentuity/local/stream/${id}`;
|
|
647
|
+
|
|
648
|
+
return new LocalStream(
|
|
649
|
+
id,
|
|
650
|
+
url,
|
|
651
|
+
this.#db,
|
|
652
|
+
this.#projectPath,
|
|
653
|
+
this.#tempDir,
|
|
654
|
+
props?.compress ?? false
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async list(params?: ListStreamsParams): Promise<ListStreamsResponse> {
|
|
659
|
+
if (params?.limit && (params.limit <= 0 || params.limit > 1000)) {
|
|
660
|
+
throw new Error('limit must be between 1 and 1000');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
let query = `
|
|
664
|
+
SELECT id, name, metadata, size_bytes
|
|
665
|
+
FROM stream_storage
|
|
666
|
+
WHERE project_path = ?
|
|
667
|
+
`;
|
|
668
|
+
const queryParams: any[] = [this.#projectPath];
|
|
669
|
+
|
|
670
|
+
// Add filters
|
|
671
|
+
if (params?.name) {
|
|
672
|
+
query += ` AND name = ?`;
|
|
673
|
+
queryParams.push(params.name);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (params?.metadata) {
|
|
677
|
+
// Simple JSON matching - check if metadata contains all key-value pairs
|
|
678
|
+
for (const [key, value] of Object.entries(params.metadata)) {
|
|
679
|
+
query += ` AND metadata LIKE ?`;
|
|
680
|
+
queryParams.push(`%"${key}":"${value}"%`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Get total count
|
|
685
|
+
const countQuery = this.#db.query(
|
|
686
|
+
query.replace('SELECT id, name, metadata, size_bytes', 'SELECT COUNT(*) as count')
|
|
687
|
+
);
|
|
688
|
+
const { count } = countQuery.get(...queryParams) as { count: number };
|
|
689
|
+
|
|
690
|
+
// Add pagination
|
|
691
|
+
query += ` ORDER BY created_at DESC`;
|
|
692
|
+
if (params?.limit) {
|
|
693
|
+
query += ` LIMIT ${params.limit}`;
|
|
694
|
+
}
|
|
695
|
+
if (params?.offset) {
|
|
696
|
+
query += ` OFFSET ${params.offset}`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const stmt = this.#db.query(query);
|
|
700
|
+
const rows = stmt.all(...queryParams) as Array<{
|
|
701
|
+
id: string;
|
|
702
|
+
name: string;
|
|
703
|
+
metadata: string | null;
|
|
704
|
+
size_bytes: number;
|
|
705
|
+
}>;
|
|
706
|
+
|
|
707
|
+
const streams: StreamInfo[] = rows.map((row) => ({
|
|
708
|
+
id: row.id,
|
|
709
|
+
name: row.name,
|
|
710
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
711
|
+
url: `${this.#serverUrl}/_agentuity/local/stream/${row.id}`,
|
|
712
|
+
sizeBytes: row.size_bytes,
|
|
713
|
+
}));
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
success: true,
|
|
717
|
+
streams,
|
|
718
|
+
total: count,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async delete(id: string): Promise<void> {
|
|
723
|
+
if (!id?.trim()) {
|
|
724
|
+
throw new Error('Stream id is required');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const stmt = this.#db.prepare(`
|
|
728
|
+
DELETE FROM stream_storage
|
|
729
|
+
WHERE project_path = ? AND id = ?
|
|
730
|
+
`);
|
|
731
|
+
|
|
732
|
+
stmt.run(this.#projectPath, id);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
class LocalStream extends WritableStream implements Stream {
|
|
737
|
+
public readonly id: string;
|
|
738
|
+
public readonly url: string;
|
|
739
|
+
|
|
740
|
+
#db: Database;
|
|
741
|
+
#projectPath: string;
|
|
742
|
+
#compressed: boolean;
|
|
743
|
+
#tempFilePath: string;
|
|
744
|
+
#fileHandle: number | null = null;
|
|
745
|
+
#bytesWritten = 0;
|
|
746
|
+
#closed = false;
|
|
747
|
+
|
|
748
|
+
constructor(
|
|
749
|
+
id: string,
|
|
750
|
+
url: string,
|
|
751
|
+
db: Database,
|
|
752
|
+
projectPath: string,
|
|
753
|
+
tempDir: string,
|
|
754
|
+
compressed: boolean
|
|
755
|
+
) {
|
|
756
|
+
super({
|
|
757
|
+
write: async (chunk: Uint8Array) => {
|
|
758
|
+
await this.#writeToFile(chunk);
|
|
759
|
+
},
|
|
760
|
+
close: async () => {
|
|
761
|
+
await this.#persist();
|
|
762
|
+
},
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
this.id = id;
|
|
766
|
+
this.url = url;
|
|
767
|
+
this.#db = db;
|
|
768
|
+
this.#projectPath = projectPath;
|
|
769
|
+
this.#compressed = compressed;
|
|
770
|
+
this.#tempFilePath = join(tempDir, `${id}.tmp`);
|
|
771
|
+
|
|
772
|
+
// Open file for writing
|
|
773
|
+
this.#fileHandle = openSync(this.#tempFilePath, 'w');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
get bytesWritten(): number {
|
|
777
|
+
return this.#bytesWritten;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
get compressed(): boolean {
|
|
781
|
+
return this.#compressed;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async write(chunk: string | Uint8Array | ArrayBuffer | Buffer | object): Promise<void> {
|
|
785
|
+
if (this.#closed) {
|
|
786
|
+
throw new Error('Stream is closed');
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
let binary: Uint8Array;
|
|
790
|
+
if (chunk instanceof Uint8Array) {
|
|
791
|
+
binary = chunk;
|
|
792
|
+
} else if (typeof chunk === 'string') {
|
|
793
|
+
binary = new TextEncoder().encode(chunk);
|
|
794
|
+
} else if (chunk instanceof ArrayBuffer) {
|
|
795
|
+
binary = new Uint8Array(chunk);
|
|
796
|
+
} else if (typeof chunk === 'object') {
|
|
797
|
+
binary = new TextEncoder().encode(JSON.stringify(chunk));
|
|
798
|
+
} else {
|
|
799
|
+
binary = new TextEncoder().encode(String(chunk));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
await this.#writeToFile(binary);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async close(): Promise<void> {
|
|
806
|
+
if (this.#closed) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
this.#closed = true;
|
|
811
|
+
|
|
812
|
+
// Close file handle if open
|
|
813
|
+
if (this.#fileHandle !== null) {
|
|
814
|
+
closeSync(this.#fileHandle);
|
|
815
|
+
this.#fileHandle = null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
await this.#persist();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
getReader(): ReadableStream<Uint8Array> {
|
|
822
|
+
const db = this.#db;
|
|
823
|
+
const projectPath = this.#projectPath;
|
|
824
|
+
const id = this.id;
|
|
825
|
+
|
|
826
|
+
return new ReadableStream({
|
|
827
|
+
start(controller) {
|
|
828
|
+
const query = db.query(`
|
|
829
|
+
SELECT data FROM stream_storage
|
|
830
|
+
WHERE project_path = ? AND id = ?
|
|
831
|
+
`);
|
|
832
|
+
|
|
833
|
+
const row = query.get(projectPath, id) as { data: Buffer | null } | null;
|
|
834
|
+
|
|
835
|
+
if (!row || !row.data) {
|
|
836
|
+
controller.error(new Error('Stream not found or not finalized'));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
controller.enqueue(new Uint8Array(row.data));
|
|
841
|
+
controller.close();
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async #writeToFile(chunk: Uint8Array): Promise<void> {
|
|
847
|
+
if (this.#fileHandle === null) {
|
|
848
|
+
throw new Error('File handle is closed');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const written = writeSync(this.#fileHandle, chunk);
|
|
852
|
+
this.#bytesWritten += written;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async #persist(): Promise<void> {
|
|
856
|
+
// Read buffered file
|
|
857
|
+
let data = readFileSync(this.#tempFilePath);
|
|
858
|
+
|
|
859
|
+
// Optional: Apply compression if enabled
|
|
860
|
+
if (this.#compressed) {
|
|
861
|
+
const { gzipSync } = await import('node:zlib');
|
|
862
|
+
data = gzipSync(data);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Update DB with finalized data
|
|
866
|
+
const stmt = this.#db.prepare(`
|
|
867
|
+
UPDATE stream_storage
|
|
868
|
+
SET data = ?, size_bytes = ?
|
|
869
|
+
WHERE project_path = ? AND id = ?
|
|
870
|
+
`);
|
|
871
|
+
|
|
872
|
+
stmt.run(data, this.#bytesWritten, this.#projectPath, this.id);
|
|
873
|
+
|
|
874
|
+
// Clean up temp file
|
|
875
|
+
try {
|
|
876
|
+
unlinkSync(this.#tempFilePath);
|
|
877
|
+
} catch (err) {
|
|
878
|
+
// Ignore cleanup errors
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
**Key Features**:
|
|
885
|
+
|
|
886
|
+
- File-based buffering to `~/.config/agentuity/streams/{id}.tmp`
|
|
887
|
+
- Avoids memory pressure for large streams
|
|
888
|
+
- `getReader()` reads from finalized DB data
|
|
889
|
+
- Optional gzip compression support
|
|
890
|
+
- Metadata filtering in `list()` with JSON LIKE queries
|
|
891
|
+
- Public URL generation
|
|
892
|
+
- Automatic temp file cleanup after persist
|
|
893
|
+
|
|
894
|
+
### 6. Vector Storage (`vector.ts`)
|
|
895
|
+
|
|
896
|
+
```typescript
|
|
897
|
+
import type { Database } from 'bun:sqlite';
|
|
898
|
+
import type {
|
|
899
|
+
VectorStorage,
|
|
900
|
+
VectorUpsertParams,
|
|
901
|
+
VectorUpsertResult,
|
|
902
|
+
VectorResult,
|
|
903
|
+
VectorResultNotFound,
|
|
904
|
+
VectorSearchResultWithDocument,
|
|
905
|
+
VectorSearchParams,
|
|
906
|
+
VectorSearchResult,
|
|
907
|
+
} from '@agentuity/core';
|
|
908
|
+
import { simpleEmbedding, cosineSimilarity, now } from './_util';
|
|
909
|
+
|
|
910
|
+
export class LocalVectorStorage implements VectorStorage {
|
|
911
|
+
#db: Database;
|
|
912
|
+
#projectPath: string;
|
|
913
|
+
|
|
914
|
+
constructor(db: Database, projectPath: string) {
|
|
915
|
+
this.#db = db;
|
|
916
|
+
this.#projectPath = projectPath;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async upsert(name: string, ...documents: VectorUpsertParams[]): Promise<VectorUpsertResult[]> {
|
|
920
|
+
if (!name?.trim()) {
|
|
921
|
+
throw new Error('Vector storage name is required');
|
|
922
|
+
}
|
|
923
|
+
if (documents.length === 0) {
|
|
924
|
+
throw new Error('At least one document is required');
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const results: VectorUpsertResult[] = [];
|
|
928
|
+
const stmt = this.#db.prepare(`
|
|
929
|
+
INSERT INTO vector_storage (
|
|
930
|
+
project_path, name, id, key, embedding, document, metadata, created_at, updated_at
|
|
931
|
+
)
|
|
932
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
933
|
+
ON CONFLICT(project_path, name, key)
|
|
934
|
+
DO UPDATE SET
|
|
935
|
+
embedding = excluded.embedding,
|
|
936
|
+
document = excluded.document,
|
|
937
|
+
metadata = excluded.metadata,
|
|
938
|
+
updated_at = excluded.updated_at
|
|
939
|
+
`);
|
|
940
|
+
|
|
941
|
+
for (const doc of documents) {
|
|
942
|
+
if (!doc.key?.trim()) {
|
|
943
|
+
throw new Error('Each document must have a non-empty key');
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Generate or use provided embeddings
|
|
947
|
+
let embedding: number[];
|
|
948
|
+
if ('embeddings' in doc && doc.embeddings) {
|
|
949
|
+
if (!Array.isArray(doc.embeddings) || doc.embeddings.length === 0) {
|
|
950
|
+
throw new Error('Embeddings must be a non-empty array');
|
|
951
|
+
}
|
|
952
|
+
embedding = doc.embeddings;
|
|
953
|
+
} else if ('document' in doc && doc.document) {
|
|
954
|
+
if (!doc.document?.trim()) {
|
|
955
|
+
throw new Error('Document text must be non-empty');
|
|
956
|
+
}
|
|
957
|
+
embedding = simpleEmbedding(doc.document);
|
|
958
|
+
} else {
|
|
959
|
+
throw new Error('Each document must have either embeddings or document text');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const id = crypto.randomUUID();
|
|
963
|
+
const timestamp = now();
|
|
964
|
+
const embeddingJson = JSON.stringify(embedding);
|
|
965
|
+
const documentText = 'document' in doc ? doc.document : null;
|
|
966
|
+
const metadata = doc.metadata ? JSON.stringify(doc.metadata) : null;
|
|
967
|
+
|
|
968
|
+
stmt.run(
|
|
969
|
+
this.#projectPath,
|
|
970
|
+
name,
|
|
971
|
+
id,
|
|
972
|
+
doc.key,
|
|
973
|
+
embeddingJson,
|
|
974
|
+
documentText,
|
|
975
|
+
metadata,
|
|
976
|
+
timestamp,
|
|
977
|
+
timestamp
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
results.push({ key: doc.key, id });
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return results;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async get<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
987
|
+
name: string,
|
|
988
|
+
key: string
|
|
989
|
+
): Promise<VectorResult<T>> {
|
|
990
|
+
if (!name?.trim() || !key?.trim()) {
|
|
991
|
+
throw new Error('Vector storage name and key are required');
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const query = this.#db.query(`
|
|
995
|
+
SELECT id, key, embedding, document, metadata
|
|
996
|
+
FROM vector_storage
|
|
997
|
+
WHERE project_path = ? AND name = ? AND key = ?
|
|
998
|
+
`);
|
|
999
|
+
|
|
1000
|
+
const row = query.get(this.#projectPath, name, key) as {
|
|
1001
|
+
id: string;
|
|
1002
|
+
key: string;
|
|
1003
|
+
embedding: string;
|
|
1004
|
+
document: string | null;
|
|
1005
|
+
metadata: string | null;
|
|
1006
|
+
} | null;
|
|
1007
|
+
|
|
1008
|
+
if (!row) {
|
|
1009
|
+
return { exists: false } as VectorResultNotFound;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return {
|
|
1013
|
+
exists: true,
|
|
1014
|
+
data: {
|
|
1015
|
+
id: row.id,
|
|
1016
|
+
key: row.key,
|
|
1017
|
+
embeddings: JSON.parse(row.embedding),
|
|
1018
|
+
document: row.document || undefined,
|
|
1019
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
1020
|
+
similarity: 1.0, // Perfect match for direct get
|
|
1021
|
+
} as VectorSearchResultWithDocument<T>,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async getMany<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
1026
|
+
name: string,
|
|
1027
|
+
...keys: string[]
|
|
1028
|
+
): Promise<Map<string, VectorSearchResultWithDocument<T>>> {
|
|
1029
|
+
if (!name?.trim()) {
|
|
1030
|
+
throw new Error('Vector storage name is required');
|
|
1031
|
+
}
|
|
1032
|
+
if (keys.length === 0) {
|
|
1033
|
+
return new Map();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const results = await Promise.all(
|
|
1037
|
+
keys.map(async (key) => {
|
|
1038
|
+
const result = await this.get<T>(name, key);
|
|
1039
|
+
return { key, result };
|
|
1040
|
+
})
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
const map = new Map<string, VectorSearchResultWithDocument<T>>();
|
|
1044
|
+
for (const { key, result } of results) {
|
|
1045
|
+
if (result.exists) {
|
|
1046
|
+
map.set(key, result.data);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return map;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async search<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
1054
|
+
name: string,
|
|
1055
|
+
params: VectorSearchParams<T>
|
|
1056
|
+
): Promise<VectorSearchResult<T>[]> {
|
|
1057
|
+
if (!name?.trim()) {
|
|
1058
|
+
throw new Error('Vector storage name is required');
|
|
1059
|
+
}
|
|
1060
|
+
if (!params.query?.trim()) {
|
|
1061
|
+
throw new Error('Query is required');
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Generate query embedding
|
|
1065
|
+
const queryEmbedding = simpleEmbedding(params.query);
|
|
1066
|
+
|
|
1067
|
+
// Fetch all vectors for this name
|
|
1068
|
+
const query = this.#db.query(`
|
|
1069
|
+
SELECT id, key, embedding, metadata
|
|
1070
|
+
FROM vector_storage
|
|
1071
|
+
WHERE project_path = ? AND name = ?
|
|
1072
|
+
`);
|
|
1073
|
+
|
|
1074
|
+
const rows = query.all(this.#projectPath, name) as Array<{
|
|
1075
|
+
id: string;
|
|
1076
|
+
key: string;
|
|
1077
|
+
embedding: string;
|
|
1078
|
+
metadata: string | null;
|
|
1079
|
+
}>;
|
|
1080
|
+
|
|
1081
|
+
// Calculate similarities
|
|
1082
|
+
const results: Array<VectorSearchResult<T> & { similarity: number }> = [];
|
|
1083
|
+
|
|
1084
|
+
for (const row of rows) {
|
|
1085
|
+
const embedding = JSON.parse(row.embedding);
|
|
1086
|
+
const similarity = cosineSimilarity(queryEmbedding, embedding);
|
|
1087
|
+
|
|
1088
|
+
// Apply similarity threshold
|
|
1089
|
+
if (params.similarity !== undefined && similarity < params.similarity) {
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Apply metadata filter
|
|
1094
|
+
if (params.metadata) {
|
|
1095
|
+
const rowMetadata = row.metadata ? JSON.parse(row.metadata) : {};
|
|
1096
|
+
const matches = Object.entries(params.metadata).every(
|
|
1097
|
+
([key, value]) => rowMetadata[key] === value
|
|
1098
|
+
);
|
|
1099
|
+
if (!matches) {
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
results.push({
|
|
1105
|
+
id: row.id,
|
|
1106
|
+
key: row.key,
|
|
1107
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
|
1108
|
+
similarity,
|
|
1109
|
+
} as VectorSearchResult<T> & { similarity: number });
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Sort by similarity descending
|
|
1113
|
+
results.sort((a, b) => b.similarity - a.similarity);
|
|
1114
|
+
|
|
1115
|
+
// Apply limit
|
|
1116
|
+
const limit = params.limit || 10;
|
|
1117
|
+
return results.slice(0, limit);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async delete(name: string, ...keys: string[]): Promise<number> {
|
|
1121
|
+
if (!name?.trim()) {
|
|
1122
|
+
throw new Error('Vector storage name is required');
|
|
1123
|
+
}
|
|
1124
|
+
if (keys.length === 0) {
|
|
1125
|
+
return 0;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
1129
|
+
const stmt = this.#db.prepare(`
|
|
1130
|
+
DELETE FROM vector_storage
|
|
1131
|
+
WHERE project_path = ? AND name = ? AND key IN (${placeholders})
|
|
1132
|
+
`);
|
|
1133
|
+
|
|
1134
|
+
const result = stmt.run(this.#projectPath, name, ...keys);
|
|
1135
|
+
return result.changes;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async exists(name: string): Promise<boolean> {
|
|
1139
|
+
if (!name?.trim()) {
|
|
1140
|
+
throw new Error('Vector storage name is required');
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const query = this.#db.query(`
|
|
1144
|
+
SELECT COUNT(*) as count
|
|
1145
|
+
FROM vector_storage
|
|
1146
|
+
WHERE project_path = ? AND name = ?
|
|
1147
|
+
`);
|
|
1148
|
+
|
|
1149
|
+
const { count } = query.get(this.#projectPath, name) as { count: number };
|
|
1150
|
+
return count > 0;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
**Key Features**:
|
|
1156
|
+
|
|
1157
|
+
- Auto-generates embeddings from document text using `simpleEmbedding()`
|
|
1158
|
+
- Brute-force similarity search (acceptable for local dev)
|
|
1159
|
+
- Metadata filtering with deep equality check
|
|
1160
|
+
- `exists()` checks for any vectors in the named storage
|
|
1161
|
+
|
|
1162
|
+
### 7. HTTP Router (`_router.ts`)
|
|
1163
|
+
|
|
1164
|
+
```typescript
|
|
1165
|
+
import type { Database } from 'bun:sqlite';
|
|
1166
|
+
import { createRouter } from '../../router';
|
|
1167
|
+
|
|
1168
|
+
export function createLocalStorageRouter(db: Database, projectPath: string) {
|
|
1169
|
+
const router = createRouter();
|
|
1170
|
+
|
|
1171
|
+
// Serve objects: GET /_agentuity/local/object/:bucket/:key
|
|
1172
|
+
router.get('/_agentuity/local/object/:bucket/:key', async (c) => {
|
|
1173
|
+
const bucket = c.req.param('bucket');
|
|
1174
|
+
const key = c.req.param('key');
|
|
1175
|
+
|
|
1176
|
+
const query = db.query(`
|
|
1177
|
+
SELECT data, content_type, content_encoding, cache_control,
|
|
1178
|
+
content_disposition, content_language
|
|
1179
|
+
FROM object_storage
|
|
1180
|
+
WHERE project_path = ? AND bucket = ? AND key = ?
|
|
1181
|
+
`);
|
|
1182
|
+
|
|
1183
|
+
const row = query.get(projectPath, bucket, key) as {
|
|
1184
|
+
data: Buffer;
|
|
1185
|
+
content_type: string;
|
|
1186
|
+
content_encoding: string | null;
|
|
1187
|
+
cache_control: string | null;
|
|
1188
|
+
content_disposition: string | null;
|
|
1189
|
+
content_language: string | null;
|
|
1190
|
+
} | null;
|
|
1191
|
+
|
|
1192
|
+
if (!row) {
|
|
1193
|
+
return c.notFound();
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Set headers
|
|
1197
|
+
const headers: Record<string, string> = {
|
|
1198
|
+
'Content-Type': row.content_type,
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
if (row.content_encoding) {
|
|
1202
|
+
headers['Content-Encoding'] = row.content_encoding;
|
|
1203
|
+
}
|
|
1204
|
+
if (row.cache_control) {
|
|
1205
|
+
headers['Cache-Control'] = row.cache_control;
|
|
1206
|
+
}
|
|
1207
|
+
if (row.content_disposition) {
|
|
1208
|
+
headers['Content-Disposition'] = row.content_disposition;
|
|
1209
|
+
}
|
|
1210
|
+
if (row.content_language) {
|
|
1211
|
+
headers['Content-Language'] = row.content_language;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
return c.body(row.data, 200, headers);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// Serve streams: GET /_agentuity/local/stream/:id
|
|
1218
|
+
router.get('/_agentuity/local/stream/:id', async (c) => {
|
|
1219
|
+
const id = c.req.param('id');
|
|
1220
|
+
|
|
1221
|
+
const query = db.query(`
|
|
1222
|
+
SELECT data, content_type
|
|
1223
|
+
FROM stream_storage
|
|
1224
|
+
WHERE project_path = ? AND id = ?
|
|
1225
|
+
`);
|
|
1226
|
+
|
|
1227
|
+
const row = query.get(projectPath, id) as {
|
|
1228
|
+
data: Buffer | null;
|
|
1229
|
+
content_type: string;
|
|
1230
|
+
} | null;
|
|
1231
|
+
|
|
1232
|
+
if (!row) {
|
|
1233
|
+
return c.notFound();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (!row.data) {
|
|
1237
|
+
return c.json({ error: 'Stream not finalized' }, 400);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
return c.body(row.data, 200, {
|
|
1241
|
+
'Content-Type': row.content_type,
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
return router;
|
|
1246
|
+
}
|
|
1247
|
+
```
|
|
1248
|
+
|
|
1249
|
+
**Key Features**:
|
|
1250
|
+
|
|
1251
|
+
- Serves object storage files with all HTTP headers
|
|
1252
|
+
- Serves stream storage files
|
|
1253
|
+
- Returns 404 for missing objects/streams
|
|
1254
|
+
- Returns 400 for streams not yet finalized
|
|
1255
|
+
|
|
1256
|
+
### 8. Public Exports (`index.ts`)
|
|
1257
|
+
|
|
1258
|
+
```typescript
|
|
1259
|
+
export { getLocalDB, closeLocalDB } from './_db';
|
|
1260
|
+
export { normalizeProjectPath, simpleEmbedding, cosineSimilarity } from './_util';
|
|
1261
|
+
export { createLocalStorageRouter } from './_router';
|
|
1262
|
+
export { LocalKeyValueStorage } from './keyvalue';
|
|
1263
|
+
export { LocalObjectStorage } from './objectstore';
|
|
1264
|
+
export { LocalStreamStorage } from './stream';
|
|
1265
|
+
export { LocalVectorStorage } from './vector';
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
---
|
|
1269
|
+
|
|
1270
|
+
## Integration
|
|
1271
|
+
|
|
1272
|
+
### 1. Update AppConfig Interface
|
|
1273
|
+
|
|
1274
|
+
**File**: `packages/runtime/src/app.ts`
|
|
1275
|
+
|
|
1276
|
+
Add new config option:
|
|
1277
|
+
|
|
1278
|
+
```typescript
|
|
1279
|
+
export interface AppConfig {
|
|
1280
|
+
// ... existing fields
|
|
1281
|
+
services?: {
|
|
1282
|
+
useLocal?: boolean;
|
|
1283
|
+
keyvalue?: KeyValueStorage;
|
|
1284
|
+
object?: ObjectStorage;
|
|
1285
|
+
stream?: StreamStorage;
|
|
1286
|
+
vector?: VectorStorage;
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
### 2. Update Service Creation
|
|
1292
|
+
|
|
1293
|
+
**File**: `packages/runtime/src/_services.ts`
|
|
1294
|
+
|
|
1295
|
+
```typescript
|
|
1296
|
+
import {
|
|
1297
|
+
LocalKeyValueStorage,
|
|
1298
|
+
LocalObjectStorage,
|
|
1299
|
+
LocalStreamStorage,
|
|
1300
|
+
LocalVectorStorage,
|
|
1301
|
+
getLocalDB,
|
|
1302
|
+
normalizeProjectPath,
|
|
1303
|
+
createLocalStorageRouter,
|
|
1304
|
+
} from './services/local';
|
|
1305
|
+
import type { Hono } from 'hono';
|
|
1306
|
+
|
|
1307
|
+
let localRouter: Hono | null = null;
|
|
1308
|
+
|
|
1309
|
+
export function createServices(config?: AppConfig, serverUrl?: string) {
|
|
1310
|
+
const authenticated = isAuthenticated();
|
|
1311
|
+
const useLocal = config?.services?.useLocal ?? false;
|
|
1312
|
+
|
|
1313
|
+
if (useLocal) {
|
|
1314
|
+
const db = getLocalDB();
|
|
1315
|
+
const projectPath = normalizeProjectPath();
|
|
1316
|
+
|
|
1317
|
+
if (!serverUrl) {
|
|
1318
|
+
throw new Error('serverUrl is required when using local services');
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
kv = config?.services?.keyvalue || new LocalKeyValueStorage(db, projectPath);
|
|
1322
|
+
objectStore = config?.services?.object || new LocalObjectStorage(db, projectPath, serverUrl);
|
|
1323
|
+
stream = config?.services?.stream || new LocalStreamStorage(db, projectPath, serverUrl);
|
|
1324
|
+
vector = config?.services?.vector || new LocalVectorStorage(db, projectPath);
|
|
1325
|
+
|
|
1326
|
+
localRouter = createLocalStorageRouter(db, projectPath);
|
|
1327
|
+
|
|
1328
|
+
return { localRouter };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Reset local router if not using local services
|
|
1332
|
+
localRouter = null;
|
|
1333
|
+
|
|
1334
|
+
// ... existing authentication logic
|
|
1335
|
+
if (config?.services?.keyvalue) {
|
|
1336
|
+
kv = config.services.keyvalue;
|
|
1337
|
+
} else if (authenticated) {
|
|
1338
|
+
kv = new KeyValueStorageService(kvBaseUrl, adapter);
|
|
1339
|
+
} else {
|
|
1340
|
+
kv = new UnauthenticatedKeyValueStorage();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// ... similar for other services
|
|
1344
|
+
|
|
1345
|
+
return {};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
export function getLocalRouter(): Hono | null {
|
|
1349
|
+
return localRouter;
|
|
1350
|
+
}
|
|
1351
|
+
```
|
|
1352
|
+
|
|
1353
|
+
### 3. Update App Creation
|
|
1354
|
+
|
|
1355
|
+
**File**: `packages/runtime/src/app.ts`
|
|
1356
|
+
|
|
1357
|
+
```typescript
|
|
1358
|
+
export function createApp(config?: AppConfig) {
|
|
1359
|
+
// ... existing setup
|
|
1360
|
+
|
|
1361
|
+
const server = createServer(config);
|
|
1362
|
+
|
|
1363
|
+
// Create services with server URL
|
|
1364
|
+
const servicesResult = createServices(config, server.url.toString());
|
|
1365
|
+
|
|
1366
|
+
// ... existing app setup
|
|
1367
|
+
|
|
1368
|
+
// Mount local router if present
|
|
1369
|
+
if (servicesResult?.localRouter) {
|
|
1370
|
+
router.route('/', servicesResult.localRouter);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// ... rest of setup
|
|
1374
|
+
|
|
1375
|
+
return { router, server, logger };
|
|
1376
|
+
}
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
### 4. Update Unauth App
|
|
1380
|
+
|
|
1381
|
+
**File**: `apps/testing/unauth-app/app.ts`
|
|
1382
|
+
|
|
1383
|
+
```typescript
|
|
1384
|
+
import { createApp } from '@agentuity/runtime';
|
|
1385
|
+
import { showRoutes } from 'hono/dev';
|
|
1386
|
+
|
|
1387
|
+
// No need to specify useLocal - it's automatic when unauthenticated
|
|
1388
|
+
const { router, server, logger } = createApp();
|
|
1389
|
+
|
|
1390
|
+
showRoutes(router);
|
|
1391
|
+
|
|
1392
|
+
logger.info('Running with local SQLite services at %s', server.url);
|
|
1393
|
+
logger.info('Database location: ~/.config/agentuity/local.db');
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1396
|
+
**Note**: The `useLocal: true` config is no longer needed. Local services are automatically used when `AGENTUITY_SDK_KEY` is not set.
|
|
1397
|
+
|
|
1398
|
+
---
|
|
1399
|
+
|
|
1400
|
+
## Testing Strategy
|
|
1401
|
+
|
|
1402
|
+
### Unit Tests
|
|
1403
|
+
|
|
1404
|
+
**File**: `packages/runtime/src/services/local/__test__/keyvalue.test.ts`
|
|
1405
|
+
|
|
1406
|
+
```typescript
|
|
1407
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
1408
|
+
import { Database } from 'bun:sqlite';
|
|
1409
|
+
import { LocalKeyValueStorage } from '../keyvalue';
|
|
1410
|
+
import { initializeTables } from '../_db';
|
|
1411
|
+
|
|
1412
|
+
describe('LocalKeyValueStorage', () => {
|
|
1413
|
+
let db: Database;
|
|
1414
|
+
let kv: LocalKeyValueStorage;
|
|
1415
|
+
|
|
1416
|
+
beforeEach(() => {
|
|
1417
|
+
db = new Database(':memory:');
|
|
1418
|
+
initializeTables(db);
|
|
1419
|
+
kv = new LocalKeyValueStorage(db, '/test/project');
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
afterEach(() => {
|
|
1423
|
+
db.close();
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
test('set and get string value', async () => {
|
|
1427
|
+
await kv.set('test', 'key1', 'value1');
|
|
1428
|
+
const result = await kv.get('test', 'key1');
|
|
1429
|
+
|
|
1430
|
+
expect(result.exists).toBe(true);
|
|
1431
|
+
if (result.exists) {
|
|
1432
|
+
expect(result.data).toBe('value1');
|
|
1433
|
+
expect(result.contentType).toBe('text/plain');
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
test('get non-existent key', async () => {
|
|
1438
|
+
const result = await kv.get('test', 'missing');
|
|
1439
|
+
expect(result.exists).toBe(false);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
test('TTL expiration', async () => {
|
|
1443
|
+
await kv.set('test', 'key1', 'value1', { ttl: 60 });
|
|
1444
|
+
// Would need to mock time or wait for expiration
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
// ... more tests
|
|
1448
|
+
});
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
Similar test files for:
|
|
1452
|
+
|
|
1453
|
+
- `objectstore.test.ts`
|
|
1454
|
+
- `stream.test.ts`
|
|
1455
|
+
- `vector.test.ts`
|
|
1456
|
+
- `_util.test.ts` (test embedding and similarity functions)
|
|
1457
|
+
|
|
1458
|
+
### Integration Tests
|
|
1459
|
+
|
|
1460
|
+
**File**: `apps/testing/unauth-app/test.ts`
|
|
1461
|
+
|
|
1462
|
+
Update to test all 4 services:
|
|
1463
|
+
|
|
1464
|
+
```typescript
|
|
1465
|
+
// Test KeyValue
|
|
1466
|
+
await ctx.kv.set('test', 'key1', { hello: 'world' });
|
|
1467
|
+
const kvResult = await ctx.kv.get('test', 'key1');
|
|
1468
|
+
console.log('KV:', kvResult);
|
|
1469
|
+
|
|
1470
|
+
// Test ObjectStore
|
|
1471
|
+
const data = new TextEncoder().encode('test object');
|
|
1472
|
+
await ctx.objectstore.put('bucket1', 'file.txt', data);
|
|
1473
|
+
const objResult = await ctx.objectstore.get('bucket1', 'file.txt');
|
|
1474
|
+
console.log('Object:', objResult);
|
|
1475
|
+
|
|
1476
|
+
// Test Stream
|
|
1477
|
+
const stream = await ctx.stream.create('test-stream');
|
|
1478
|
+
await stream.write('chunk 1');
|
|
1479
|
+
await stream.write('chunk 2');
|
|
1480
|
+
await stream.close();
|
|
1481
|
+
console.log('Stream URL:', stream.url);
|
|
1482
|
+
|
|
1483
|
+
// Test Vector
|
|
1484
|
+
await ctx.vector.upsert('docs', { key: 'doc1', document: 'hello world' });
|
|
1485
|
+
const searchResults = await ctx.vector.search('docs', { query: 'world' });
|
|
1486
|
+
console.log('Vector search:', searchResults);
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
---
|
|
1490
|
+
|
|
1491
|
+
## Implementation Checklist
|
|
1492
|
+
|
|
1493
|
+
### Phase 1: Foundation
|
|
1494
|
+
|
|
1495
|
+
- [ ] Create `packages/runtime/src/services/local/` directory
|
|
1496
|
+
- [ ] Implement `_db.ts` with singleton and schema initialization
|
|
1497
|
+
- [ ] Implement `_util.ts` with path normalization and embedding functions
|
|
1498
|
+
- [ ] Add unit tests for utilities
|
|
1499
|
+
|
|
1500
|
+
### Phase 2: Service Implementations
|
|
1501
|
+
|
|
1502
|
+
- [ ] Implement `LocalKeyValueStorage` in `keyvalue.ts`
|
|
1503
|
+
- [ ] Add unit tests for KeyValue service
|
|
1504
|
+
- [ ] Implement `LocalObjectStorage` in `objectstore.ts`
|
|
1505
|
+
- [ ] Add unit tests for ObjectStorage service
|
|
1506
|
+
- [ ] Implement `LocalStreamStorage` in `stream.ts`
|
|
1507
|
+
- [ ] Add unit tests for Stream service
|
|
1508
|
+
- [ ] Implement `LocalVectorStorage` in `vector.ts`
|
|
1509
|
+
- [ ] Add unit tests for Vector service
|
|
1510
|
+
|
|
1511
|
+
### Phase 3: HTTP Router
|
|
1512
|
+
|
|
1513
|
+
- [ ] Implement `_router.ts` with object and stream endpoints
|
|
1514
|
+
- [ ] Test router endpoints manually
|
|
1515
|
+
|
|
1516
|
+
### Phase 4: Integration
|
|
1517
|
+
|
|
1518
|
+
- [ ] Update `AppConfig` interface in `app.ts`
|
|
1519
|
+
- [ ] Update `_services.ts` to support `useLocal` option
|
|
1520
|
+
- [ ] Update `createApp()` to mount local router
|
|
1521
|
+
- [ ] Create `index.ts` with public exports
|
|
1522
|
+
|
|
1523
|
+
### Phase 5: Testing & Documentation
|
|
1524
|
+
|
|
1525
|
+
- [ ] Update `apps/testing/unauth-app/app.ts` to use local services
|
|
1526
|
+
- [ ] Update `apps/testing/unauth-app/test.ts` to test all services
|
|
1527
|
+
- [ ] Run `bun run test` in unauth-app and verify all services work
|
|
1528
|
+
- [ ] Add AGENTS.md notes about local services
|
|
1529
|
+
- [ ] Update package README if needed
|
|
1530
|
+
|
|
1531
|
+
### Phase 6: Validation
|
|
1532
|
+
|
|
1533
|
+
- [ ] Run `bun run build` to ensure TypeScript compiles
|
|
1534
|
+
- [ ] Run `bun run typecheck` to verify types
|
|
1535
|
+
- [ ] Run all tests: `bun run test`
|
|
1536
|
+
- [ ] Manual testing of unauth-app
|
|
1537
|
+
- [ ] Verify SQLite DB created at `~/.config/agentuity/local.db`
|
|
1538
|
+
|
|
1539
|
+
---
|
|
1540
|
+
|
|
1541
|
+
## Open Questions
|
|
1542
|
+
|
|
1543
|
+
1. **Error Handling**: Should we add more detailed error messages or logging?
|
|
1544
|
+
2. **Cleanup**: Should we add a CLI command to clear the local DB?
|
|
1545
|
+
3. **Migration**: Do we need schema versioning for future updates?
|
|
1546
|
+
4. **Performance**: Should we add indexes for common queries?
|
|
1547
|
+
5. **Expiration**: Should we implement background cleanup for expired KV entries?
|
|
1548
|
+
|
|
1549
|
+
---
|
|
1550
|
+
|
|
1551
|
+
## Implemented Features
|
|
1552
|
+
|
|
1553
|
+
✅ **Automatic Local Services When Unauthenticated**: No configuration needed!
|
|
1554
|
+
|
|
1555
|
+
- Local SQLite services are **automatically used** when `AGENTUITY_SDK_KEY` is not set
|
|
1556
|
+
- No more `UnauthenticatedError` exceptions
|
|
1557
|
+
- Seamless development experience without authentication
|
|
1558
|
+
- Can still be explicitly enabled with `useLocal: true` if desired
|
|
1559
|
+
|
|
1560
|
+
✅ **Automatic Orphaned Project Cleanup**: On DB initialization, data from projects whose directories no longer exist is automatically deleted
|
|
1561
|
+
|
|
1562
|
+
- Queries all tables for unique `project_path` values
|
|
1563
|
+
- Checks filesystem to verify directory still exists
|
|
1564
|
+
- Excludes current project from cleanup
|
|
1565
|
+
- Deletes all orphaned data in a single transaction
|
|
1566
|
+
- Logs cleanup activity for visibility
|
|
1567
|
+
|
|
1568
|
+
## Future Enhancements
|
|
1569
|
+
|
|
1570
|
+
- Add SQLite VACUUM on cleanup
|
|
1571
|
+
- Implement proper vector index (e.g., HNSW) instead of brute-force search
|
|
1572
|
+
- Add metrics/telemetry for local service usage
|
|
1573
|
+
- Support custom embedding dimensions
|
|
1574
|
+
- Add DB migration system for schema changes
|
|
1575
|
+
- Implement background expiration cleanup job for KV entries
|
|
1576
|
+
- Add CLI tool for inspecting/managing local DB
|