clawlet 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/storage.ts CHANGED
@@ -211,3 +211,347 @@ export class LibSqlFiFoStorage<T> {
211
211
  });
212
212
  }
213
213
  }
214
+
215
+
216
+ // --- D. Knowledge Storage (FTS + Graph Index) ---
217
+
218
+ export interface KnowledgeSearchResult {
219
+ path: string;
220
+ content: string;
221
+ score: number;
222
+ }
223
+
224
+ export interface KnowledgeEdge {
225
+ source_path: string;
226
+ target_path: string;
227
+ relation_type: string;
228
+ }
229
+
230
+ export interface KnowledgeRelation {
231
+ target: string;
232
+ type: string;
233
+ }
234
+
235
+ export class LibSqlKnowledgeStorage {
236
+ private client: Client;
237
+
238
+ private constructor(url: string, authToken?: string) {
239
+ this.client = authToken ? createClient({ url, authToken }) : createClient({ url });
240
+ }
241
+
242
+ static async create(url: string, authToken?: string): Promise<LibSqlKnowledgeStorage> {
243
+ const s = new LibSqlKnowledgeStorage(url, authToken);
244
+ await s.init();
245
+ return s;
246
+ }
247
+
248
+ private async init(): Promise<void> {
249
+ await this.client.execute(`
250
+ CREATE TABLE IF NOT EXISTS knowledge_entries (
251
+ path TEXT PRIMARY KEY,
252
+ content TEXT NOT NULL,
253
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
254
+ )
255
+ `);
256
+
257
+ await this.client.execute(`
258
+ CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts
259
+ USING fts5(path, content)
260
+ `);
261
+
262
+ await this.client.execute(`
263
+ CREATE TABLE IF NOT EXISTS knowledge_edges (
264
+ source_path TEXT NOT NULL,
265
+ target_path TEXT NOT NULL,
266
+ relation_type TEXT NOT NULL,
267
+ PRIMARY KEY (source_path, target_path, relation_type)
268
+ )
269
+ `);
270
+
271
+ await this.client.execute(`
272
+ CREATE INDEX IF NOT EXISTS knowledge_edges_target_idx
273
+ ON knowledge_edges (target_path)
274
+ `);
275
+ }
276
+
277
+ async upsert(path: string, content: string, relations?: KnowledgeRelation[]): Promise<void> {
278
+ // 1. Upsert main entry
279
+ await this.client.execute({
280
+ sql: `INSERT INTO knowledge_entries (path, content, updated_at)
281
+ VALUES (?, ?, CURRENT_TIMESTAMP)
282
+ ON CONFLICT(path) DO UPDATE SET
283
+ content = excluded.content,
284
+ updated_at = excluded.updated_at`,
285
+ args: [path, content]
286
+ });
287
+
288
+ // 2. Sync FTS (delete old, insert new)
289
+ await this.client.execute({
290
+ sql: `DELETE FROM knowledge_fts WHERE path = ?`,
291
+ args: [path]
292
+ });
293
+ await this.client.execute({
294
+ sql: `INSERT INTO knowledge_fts (path, content) VALUES (?, ?)`,
295
+ args: [path, content]
296
+ });
297
+
298
+ // 3. Sync edges (delete old outgoing, insert new)
299
+ await this.client.execute({
300
+ sql: `DELETE FROM knowledge_edges WHERE source_path = ?`,
301
+ args: [path]
302
+ });
303
+
304
+ if (relations && relations.length > 0) {
305
+ for (const rel of relations) {
306
+ await this.client.execute({
307
+ sql: `INSERT OR IGNORE INTO knowledge_edges (source_path, target_path, relation_type)
308
+ VALUES (?, ?, ?)`,
309
+ args: [path, rel.target, rel.type]
310
+ });
311
+ }
312
+ }
313
+ }
314
+
315
+ async remove(path: string): Promise<void> {
316
+ await this.client.execute({
317
+ sql: `DELETE FROM knowledge_fts WHERE path = ?`,
318
+ args: [path]
319
+ });
320
+ await this.client.execute({
321
+ sql: `DELETE FROM knowledge_edges WHERE source_path = ? OR target_path = ?`,
322
+ args: [path, path]
323
+ });
324
+ await this.client.execute({
325
+ sql: `DELETE FROM knowledge_entries WHERE path = ?`,
326
+ args: [path]
327
+ });
328
+ }
329
+
330
+ async searchFulltext(query: string, limit: number = 10): Promise<KnowledgeSearchResult[]> {
331
+ const rs = await this.client.execute({
332
+ sql: `SELECT path, content, bm25(knowledge_fts) as score
333
+ FROM knowledge_fts
334
+ WHERE knowledge_fts MATCH ?
335
+ ORDER BY score
336
+ LIMIT ?`,
337
+ args: [query, limit]
338
+ });
339
+ return rs.rows.map(row => ({
340
+ path: row.path as string,
341
+ content: row.content as string,
342
+ score: row.score as number,
343
+ }));
344
+ }
345
+
346
+ async getRelated(
347
+ path: string,
348
+ direction: 'outgoing' | 'incoming' | 'both' = 'both',
349
+ relationType?: string
350
+ ): Promise<KnowledgeEdge[]> {
351
+ let sql: string;
352
+ const args: string[] = [];
353
+
354
+ if (direction === 'outgoing') {
355
+ sql = `SELECT source_path, target_path, relation_type FROM knowledge_edges WHERE source_path = ?`;
356
+ args.push(path);
357
+ } else if (direction === 'incoming') {
358
+ sql = `SELECT source_path, target_path, relation_type FROM knowledge_edges WHERE target_path = ?`;
359
+ args.push(path);
360
+ } else {
361
+ sql = `SELECT source_path, target_path, relation_type FROM knowledge_edges WHERE source_path = ? OR target_path = ?`;
362
+ args.push(path, path);
363
+ }
364
+
365
+ if (relationType) {
366
+ sql += ` AND relation_type = ?`;
367
+ args.push(relationType);
368
+ }
369
+
370
+ const rs = await this.client.execute({ sql, args });
371
+ return rs.rows.map(row => ({
372
+ source_path: row.source_path as string,
373
+ target_path: row.target_path as string,
374
+ relation_type: row.relation_type as string,
375
+ }));
376
+ }
377
+
378
+ async list(): Promise<Array<{ path: string }>> {
379
+ const rs = await this.client.execute(`SELECT path FROM knowledge_entries ORDER BY path`);
380
+ return rs.rows.map(row => ({ path: row.path as string }));
381
+ }
382
+
383
+ async searchTemporal(
384
+ startDate: string,
385
+ endDate: string,
386
+ typeFilter?: string
387
+ ): Promise<KnowledgeSearchResult[]> {
388
+ const startTs = `${startDate}T00:00:00`;
389
+ const endTs = `${endDate}T23:59:59`;
390
+
391
+ let sql = `SELECT path, content, updated_at FROM knowledge_entries
392
+ WHERE updated_at BETWEEN ? AND ?`;
393
+ const args: (string)[] = [startTs, endTs];
394
+
395
+ if (typeFilter) {
396
+ sql += ` AND path LIKE ?`;
397
+ args.push(`${typeFilter}:%`);
398
+ }
399
+
400
+ sql += ` ORDER BY updated_at DESC`;
401
+
402
+ const rs = await this.client.execute({ sql, args });
403
+ return rs.rows.map(row => ({
404
+ path: row.path as string,
405
+ content: row.content as string,
406
+ score: 0,
407
+ }));
408
+ }
409
+
410
+ async searchConflicts(
411
+ assertion: string,
412
+ targetCategory: string,
413
+ limit: number = 3
414
+ ): Promise<KnowledgeSearchResult[]> {
415
+ // Use FTS to find entries in the target category that are semantically close to the assertion
416
+ // FTS5 tokenizes the assertion and matches against content
417
+ const tokens = assertion
418
+ .toLowerCase()
419
+ .split(/[^a-z0-9]+/)
420
+ .filter(t => t.length >= 2);
421
+
422
+ if (tokens.length === 0) return [];
423
+
424
+ // Build an OR query so any overlapping term surfaces potential conflicts
425
+ const ftsQuery = tokens.join(' OR ');
426
+
427
+ const rs = await this.client.execute({
428
+ sql: `SELECT path, content, bm25(knowledge_fts) as score
429
+ FROM knowledge_fts
430
+ WHERE knowledge_fts MATCH ? AND path LIKE ?
431
+ ORDER BY score
432
+ LIMIT ?`,
433
+ args: [ftsQuery, `${targetCategory}:%`, limit]
434
+ });
435
+ return rs.rows.map(row => ({
436
+ path: row.path as string,
437
+ content: row.content as string,
438
+ score: row.score as number,
439
+ }));
440
+ }
441
+
442
+ async graphTraverse(
443
+ startNode: string,
444
+ direction: 'outbound' | 'inbound' | 'both' = 'both',
445
+ maxDepth: number = 1,
446
+ relationshipType?: string
447
+ ): Promise<Array<{ path: string; depth: number; relation_type: string }>> {
448
+ // For depth 1, use a simple query; for depth > 1, use a recursive CTE
449
+ if (maxDepth <= 1) {
450
+ return this.graphTraverseSimple(startNode, direction, relationshipType);
451
+ }
452
+
453
+ // Recursive CTE for multi-hop traversal
454
+ let cte: string;
455
+ const args: string[] = [];
456
+
457
+ if (direction === 'outbound') {
458
+ cte = `
459
+ WITH RECURSIVE traverse(node, depth, relation_type) AS (
460
+ SELECT target_path, 1, relation_type FROM knowledge_edges
461
+ WHERE source_path = ?${relationshipType ? ' AND relation_type = ?' : ''}
462
+ UNION ALL
463
+ SELECT e.target_path, t.depth + 1, e.relation_type
464
+ FROM knowledge_edges e
465
+ JOIN traverse t ON e.source_path = t.node
466
+ WHERE t.depth < ?${relationshipType ? ' AND e.relation_type = ?' : ''}
467
+ )
468
+ SELECT DISTINCT node as path, depth, relation_type FROM traverse ORDER BY depth, path`;
469
+ args.push(startNode);
470
+ if (relationshipType) args.push(relationshipType);
471
+ args.push(String(maxDepth));
472
+ if (relationshipType) args.push(relationshipType);
473
+ } else if (direction === 'inbound') {
474
+ cte = `
475
+ WITH RECURSIVE traverse(node, depth, relation_type) AS (
476
+ SELECT source_path, 1, relation_type FROM knowledge_edges
477
+ WHERE target_path = ?${relationshipType ? ' AND relation_type = ?' : ''}
478
+ UNION ALL
479
+ SELECT e.source_path, t.depth + 1, e.relation_type
480
+ FROM knowledge_edges e
481
+ JOIN traverse t ON e.target_path = t.node
482
+ WHERE t.depth < ?${relationshipType ? ' AND e.relation_type = ?' : ''}
483
+ )
484
+ SELECT DISTINCT node as path, depth, relation_type FROM traverse ORDER BY depth, path`;
485
+ args.push(startNode);
486
+ if (relationshipType) args.push(relationshipType);
487
+ args.push(String(maxDepth));
488
+ if (relationshipType) args.push(relationshipType);
489
+ } else {
490
+ // Both directions
491
+ cte = `
492
+ WITH RECURSIVE traverse(node, depth, relation_type) AS (
493
+ SELECT target_path, 1, relation_type FROM knowledge_edges
494
+ WHERE source_path = ?${relationshipType ? ' AND relation_type = ?' : ''}
495
+ UNION ALL
496
+ SELECT source_path, 1, relation_type FROM knowledge_edges
497
+ WHERE target_path = ?${relationshipType ? ' AND relation_type = ?' : ''}
498
+ UNION ALL
499
+ SELECT CASE WHEN e.source_path = t.node THEN e.target_path ELSE e.source_path END,
500
+ t.depth + 1, e.relation_type
501
+ FROM knowledge_edges e
502
+ JOIN traverse t ON (e.source_path = t.node OR e.target_path = t.node)
503
+ WHERE t.depth < ?${relationshipType ? ' AND e.relation_type = ?' : ''}
504
+ )
505
+ SELECT DISTINCT node as path, depth, relation_type FROM traverse WHERE node != ? ORDER BY depth, path`;
506
+ args.push(startNode);
507
+ if (relationshipType) args.push(relationshipType);
508
+ args.push(startNode);
509
+ if (relationshipType) args.push(relationshipType);
510
+ args.push(String(maxDepth));
511
+ if (relationshipType) args.push(relationshipType);
512
+ args.push(startNode); // exclude startNode from results
513
+ }
514
+
515
+ const rs = await this.client.execute({ sql: cte, args });
516
+ return rs.rows.map(row => ({
517
+ path: row.path as string,
518
+ depth: Number(row.depth),
519
+ relation_type: row.relation_type as string,
520
+ }));
521
+ }
522
+
523
+ private async graphTraverseSimple(
524
+ startNode: string,
525
+ direction: 'outbound' | 'inbound' | 'both',
526
+ relationshipType?: string
527
+ ): Promise<Array<{ path: string; depth: number; relation_type: string }>> {
528
+ let sql: string;
529
+ const args: string[] = [];
530
+
531
+ if (direction === 'outbound') {
532
+ sql = `SELECT target_path as path, relation_type FROM knowledge_edges WHERE source_path = ?`;
533
+ args.push(startNode);
534
+ } else if (direction === 'inbound') {
535
+ sql = `SELECT source_path as path, relation_type FROM knowledge_edges WHERE target_path = ?`;
536
+ args.push(startNode);
537
+ } else {
538
+ sql = `SELECT CASE WHEN source_path = ? THEN target_path ELSE source_path END as path, relation_type
539
+ FROM knowledge_edges WHERE source_path = ? OR target_path = ?`;
540
+ args.push(startNode, startNode, startNode);
541
+ }
542
+
543
+ if (relationshipType) {
544
+ sql += ` AND relation_type = ?`;
545
+ args.push(relationshipType);
546
+ }
547
+
548
+ sql += ` ORDER BY path`;
549
+
550
+ const rs = await this.client.execute({ sql, args });
551
+ return rs.rows.map(row => ({
552
+ path: row.path as string,
553
+ depth: 1,
554
+ relation_type: row.relation_type as string,
555
+ }));
556
+ }
557
+ }