@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.
Files changed (130) hide show
  1. package/AGENTS.md +11 -9
  2. package/README.md +4 -4
  3. package/dist/_context.d.ts +12 -4
  4. package/dist/_context.d.ts.map +1 -1
  5. package/dist/_server.d.ts +7 -4
  6. package/dist/_server.d.ts.map +1 -1
  7. package/dist/_services.d.ts +13 -2
  8. package/dist/_services.d.ts.map +1 -1
  9. package/dist/_util.d.ts +1 -1
  10. package/dist/_util.d.ts.map +1 -1
  11. package/dist/_waituntil.d.ts +1 -3
  12. package/dist/_waituntil.d.ts.map +1 -1
  13. package/dist/agent.d.ts +41 -14
  14. package/dist/agent.d.ts.map +1 -1
  15. package/dist/app.d.ts +90 -8
  16. package/dist/app.d.ts.map +1 -1
  17. package/dist/eval.d.ts +79 -0
  18. package/dist/eval.d.ts.map +1 -0
  19. package/dist/index.d.ts +6 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/io/email.d.ts +77 -0
  22. package/dist/io/email.d.ts.map +1 -0
  23. package/dist/logger/console.d.ts +7 -1
  24. package/dist/logger/console.d.ts.map +1 -1
  25. package/dist/logger/user.d.ts.map +1 -1
  26. package/dist/otel/config.d.ts +3 -1
  27. package/dist/otel/config.d.ts.map +1 -1
  28. package/dist/otel/console.d.ts +2 -1
  29. package/dist/otel/console.d.ts.map +1 -1
  30. package/dist/otel/exporters/index.d.ts +4 -0
  31. package/dist/otel/exporters/index.d.ts.map +1 -0
  32. package/dist/otel/exporters/jsonl-log-exporter.d.ts +36 -0
  33. package/dist/otel/exporters/jsonl-log-exporter.d.ts.map +1 -0
  34. package/dist/otel/exporters/jsonl-metric-exporter.d.ts +40 -0
  35. package/dist/otel/exporters/jsonl-metric-exporter.d.ts.map +1 -0
  36. package/dist/otel/exporters/jsonl-trace-exporter.d.ts +36 -0
  37. package/dist/otel/exporters/jsonl-trace-exporter.d.ts.map +1 -0
  38. package/dist/otel/http.d.ts.map +1 -1
  39. package/dist/otel/logger.d.ts +8 -6
  40. package/dist/otel/logger.d.ts.map +1 -1
  41. package/dist/otel/otel.d.ts +8 -2
  42. package/dist/otel/otel.d.ts.map +1 -1
  43. package/dist/router.d.ts +4 -1
  44. package/dist/router.d.ts.map +1 -1
  45. package/dist/services/evalrun/composite.d.ts +21 -0
  46. package/dist/services/evalrun/composite.d.ts.map +1 -0
  47. package/dist/services/evalrun/http.d.ts +24 -0
  48. package/dist/services/evalrun/http.d.ts.map +1 -0
  49. package/dist/services/evalrun/index.d.ts +5 -0
  50. package/dist/services/evalrun/index.d.ts.map +1 -0
  51. package/dist/services/evalrun/json.d.ts +21 -0
  52. package/dist/services/evalrun/json.d.ts.map +1 -0
  53. package/dist/services/evalrun/local.d.ts +19 -0
  54. package/dist/services/evalrun/local.d.ts.map +1 -0
  55. package/dist/services/local/_db.d.ts +4 -0
  56. package/dist/services/local/_db.d.ts.map +1 -0
  57. package/dist/services/local/_router.d.ts +3 -0
  58. package/dist/services/local/_router.d.ts.map +1 -0
  59. package/dist/services/local/_util.d.ts +18 -0
  60. package/dist/services/local/_util.d.ts.map +1 -0
  61. package/dist/services/local/index.d.ts +8 -0
  62. package/dist/services/local/index.d.ts.map +1 -0
  63. package/dist/services/local/keyvalue.d.ts +10 -0
  64. package/dist/services/local/keyvalue.d.ts.map +1 -0
  65. package/dist/services/local/objectstore.d.ts +11 -0
  66. package/dist/services/local/objectstore.d.ts.map +1 -0
  67. package/dist/services/local/stream.d.ts +10 -0
  68. package/dist/services/local/stream.d.ts.map +1 -0
  69. package/dist/services/local/vector.d.ts +13 -0
  70. package/dist/services/local/vector.d.ts.map +1 -0
  71. package/dist/services/session/composite.d.ts +21 -0
  72. package/dist/services/session/composite.d.ts.map +1 -0
  73. package/dist/services/session/http.d.ts +23 -0
  74. package/dist/services/session/http.d.ts.map +1 -0
  75. package/dist/services/session/index.d.ts +5 -0
  76. package/dist/services/session/index.d.ts.map +1 -0
  77. package/dist/services/session/json.d.ts +22 -0
  78. package/dist/services/session/json.d.ts.map +1 -0
  79. package/dist/services/session/local.d.ts +19 -0
  80. package/dist/services/session/local.d.ts.map +1 -0
  81. package/dist/session.d.ts +70 -0
  82. package/dist/session.d.ts.map +1 -0
  83. package/package.json +10 -6
  84. package/src/_config.ts +1 -1
  85. package/src/_context.ts +19 -16
  86. package/src/_server.ts +284 -42
  87. package/src/_services.ts +147 -34
  88. package/src/_util.ts +2 -3
  89. package/src/_waituntil.ts +5 -153
  90. package/src/agent.ts +667 -65
  91. package/src/app.ts +159 -13
  92. package/src/eval.ts +95 -0
  93. package/src/index.ts +6 -1
  94. package/src/io/email.ts +173 -0
  95. package/src/logger/console.ts +196 -17
  96. package/src/logger/user.ts +7 -3
  97. package/src/otel/config.ts +7 -44
  98. package/src/otel/console.ts +8 -4
  99. package/src/otel/exporters/README.md +217 -0
  100. package/src/otel/exporters/index.ts +3 -0
  101. package/src/otel/exporters/jsonl-log-exporter.ts +113 -0
  102. package/src/otel/exporters/jsonl-metric-exporter.ts +120 -0
  103. package/src/otel/exporters/jsonl-trace-exporter.ts +121 -0
  104. package/src/otel/http.ts +3 -1
  105. package/src/otel/logger.ts +87 -37
  106. package/src/otel/otel.ts +43 -22
  107. package/src/router.ts +44 -4
  108. package/src/services/evalrun/composite.ts +34 -0
  109. package/src/services/evalrun/http.ts +112 -0
  110. package/src/services/evalrun/index.ts +4 -0
  111. package/src/services/evalrun/json.ts +46 -0
  112. package/src/services/evalrun/local.ts +28 -0
  113. package/src/services/local/README.md +1576 -0
  114. package/src/services/local/_db.ts +182 -0
  115. package/src/services/local/_router.ts +86 -0
  116. package/src/services/local/_util.ts +49 -0
  117. package/src/services/local/index.ts +7 -0
  118. package/src/services/local/keyvalue.ts +118 -0
  119. package/src/services/local/objectstore.ts +152 -0
  120. package/src/services/local/stream.ts +296 -0
  121. package/src/services/local/vector.ts +264 -0
  122. package/src/services/session/composite.ts +33 -0
  123. package/src/services/session/http.ts +64 -0
  124. package/src/services/session/index.ts +4 -0
  125. package/src/services/session/json.ts +42 -0
  126. package/src/services/session/local.ts +28 -0
  127. package/src/session.ts +284 -0
  128. package/dist/_unauthenticated.d.ts +0 -26
  129. package/dist/_unauthenticated.d.ts.map +0 -1
  130. 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