neoagent 2.3.1-beta.98 → 2.4.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.
Files changed (52) hide show
  1. package/.env.example +6 -3
  2. package/flutter_app/lib/main.dart +1 -0
  3. package/flutter_app/lib/main_integrations.dart +21 -2
  4. package/flutter_app/lib/main_models.dart +60 -0
  5. package/flutter_app/lib/main_theme.dart +31 -2
  6. package/flutter_app/macos/Runner/AppDelegate.swift +11 -1
  7. package/flutter_app/macos/Runner/DebugProfile.entitlements +4 -0
  8. package/flutter_app/macos/Runner/Release.entitlements +4 -0
  9. package/flutter_app/pubspec.lock +5 -5
  10. package/lib/manager.js +164 -2
  11. package/package.json +1 -1
  12. package/server/db/database.js +85 -0
  13. package/server/public/.last_build_id +1 -1
  14. package/server/public/assets/NOTICES +971 -1066
  15. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  16. package/server/public/assets/shaders/ink_sparkle.frag +1 -1
  17. package/server/public/assets/shaders/stretch_effect.frag +1 -1
  18. package/server/public/canvaskit/canvaskit.js +2 -2
  19. package/server/public/canvaskit/canvaskit.js.symbols +11796 -11733
  20. package/server/public/canvaskit/canvaskit.wasm +0 -0
  21. package/server/public/canvaskit/chromium/canvaskit.js +2 -2
  22. package/server/public/canvaskit/chromium/canvaskit.js.symbols +10706 -10643
  23. package/server/public/canvaskit/chromium/canvaskit.wasm +0 -0
  24. package/server/public/canvaskit/experimental_webparagraph/canvaskit.js +171 -0
  25. package/server/public/canvaskit/experimental_webparagraph/canvaskit.js.symbols +9134 -0
  26. package/server/public/canvaskit/experimental_webparagraph/canvaskit.wasm +0 -0
  27. package/server/public/canvaskit/skwasm.js +14 -14
  28. package/server/public/canvaskit/skwasm.js.symbols +12787 -12676
  29. package/server/public/canvaskit/skwasm.wasm +0 -0
  30. package/server/public/canvaskit/skwasm_heavy.js +14 -14
  31. package/server/public/canvaskit/skwasm_heavy.js.symbols +14400 -14286
  32. package/server/public/canvaskit/skwasm_heavy.wasm +0 -0
  33. package/server/public/canvaskit/wimp.js +94 -95
  34. package/server/public/canvaskit/wimp.js.symbols +11325 -11177
  35. package/server/public/canvaskit/wimp.wasm +0 -0
  36. package/server/public/flutter_bootstrap.js +2 -2
  37. package/server/public/main.dart.js +83866 -82074
  38. package/server/routes/integrations.js +2 -2
  39. package/server/routes/memory.js +73 -0
  40. package/server/services/ai/engine.js +65 -26
  41. package/server/services/ai/models.js +21 -0
  42. package/server/services/ai/preModelCompaction.js +191 -0
  43. package/server/services/ai/providers/claudeCode.js +273 -0
  44. package/server/services/ai/providers/openaiCodex.js +226 -41
  45. package/server/services/ai/settings.js +11 -1
  46. package/server/services/integrations/google/provider.js +78 -0
  47. package/server/services/integrations/manager.js +29 -13
  48. package/server/services/manager.js +25 -0
  49. package/server/services/memory/ingestion.js +486 -0
  50. package/server/services/memory/manager.js +422 -0
  51. package/server/services/memory/openhuman_uplift.test.js +98 -0
  52. package/server/services/widgets/focus_widget.js +45 -4
@@ -117,6 +117,16 @@ function parseJsonObject(value, fallback = {}) {
117
117
  }
118
118
  }
119
119
 
120
+ function parseJsonArray(value, fallback = []) {
121
+ if (Array.isArray(value)) return [...value];
122
+ try {
123
+ const parsed = JSON.parse(String(value || '[]'));
124
+ return Array.isArray(parsed) ? parsed : [...fallback];
125
+ } catch {
126
+ return [...fallback];
127
+ }
128
+ }
129
+
120
130
  function computeFreshnessMultiplier(row) {
121
131
  const staleAfterDays = Number(row?.stale_after_days);
122
132
  if (!Number.isFinite(staleAfterDays) || staleAfterDays <= 0) return 1;
@@ -194,6 +204,14 @@ function tokenizeRecallQuery(query) {
194
204
  .slice(0, 12);
195
205
  }
196
206
 
207
+ function normalizeStringArray(value, maxItems = 24, maxLength = 160) {
208
+ return [...new Set(
209
+ (Array.isArray(value) ? value : [])
210
+ .map((item) => String(item || '').trim().slice(0, maxLength))
211
+ .filter(Boolean)
212
+ )].slice(0, maxItems);
213
+ }
214
+
197
215
  function scoreSchedulerRunMatch(queryTokens, title, finalResponse) {
198
216
  if (!queryTokens.length) return 0;
199
217
  const haystack = `${String(title || '')} ${String(finalResponse || '')}`.toLowerCase();
@@ -313,6 +331,410 @@ class MemoryManager {
313
331
  return this.getAssistantSelfState(userId, { agentId });
314
332
  }
315
333
 
334
+ listIngestionJobs(userId, { agentId = null, sourceType = null, providerKey = null, limit = 25 } = {}) {
335
+ const scopedAgentId = this._agentId(userId, { agentId });
336
+ let sql = `SELECT *
337
+ FROM memory_ingestion_jobs
338
+ WHERE user_id = ? AND agent_id = ?`;
339
+ const params = [userId, scopedAgentId];
340
+ if (sourceType) {
341
+ sql += ' AND source_type = ?';
342
+ params.push(String(sourceType).trim());
343
+ }
344
+ if (providerKey) {
345
+ sql += ' AND provider_key = ?';
346
+ params.push(String(providerKey).trim());
347
+ }
348
+ sql += ' ORDER BY updated_at DESC LIMIT ?';
349
+ params.push(Math.max(1, Math.min(Number(limit) || 25, 100)));
350
+ return db.prepare(sql).all(...params).map((row) => ({
351
+ id: row.id,
352
+ sourceType: row.source_type,
353
+ providerKey: row.provider_key || null,
354
+ connectionId: row.connection_id == null ? null : Number(row.connection_id),
355
+ status: row.status || 'pending',
356
+ freshnessPolicy: parseJsonObject(row.freshness_policy_json, {}),
357
+ cursor: parseJsonObject(row.cursor_json, {}),
358
+ summary: parseJsonObject(row.summary_json, {}),
359
+ metadata: parseJsonObject(row.metadata_json, {}),
360
+ documentCount: Number(row.document_count || 0),
361
+ error: row.error_text || null,
362
+ startedAt: row.started_at || null,
363
+ completedAt: row.completed_at || null,
364
+ nextSyncAt: row.next_sync_at || null,
365
+ updatedAt: row.updated_at || null,
366
+ }));
367
+ }
368
+
369
+ recordIngestionJob(userId, job = {}, options = {}) {
370
+ const scopedAgentId = this._agentId(userId, options);
371
+ const jobId = String(job.id || uuidv4()).trim();
372
+ db.prepare(
373
+ `INSERT INTO memory_ingestion_jobs (
374
+ id, user_id, agent_id, source_type, provider_key, connection_id, status,
375
+ freshness_policy_json, cursor_json, summary_json, metadata_json, document_count,
376
+ error_text, started_at, completed_at, next_sync_at, created_at, updated_at
377
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, datetime('now')), ?, ?, datetime('now'), datetime('now'))
378
+ ON CONFLICT(id) DO UPDATE SET
379
+ status = excluded.status,
380
+ freshness_policy_json = excluded.freshness_policy_json,
381
+ cursor_json = excluded.cursor_json,
382
+ summary_json = excluded.summary_json,
383
+ metadata_json = excluded.metadata_json,
384
+ document_count = excluded.document_count,
385
+ error_text = excluded.error_text,
386
+ completed_at = excluded.completed_at,
387
+ next_sync_at = excluded.next_sync_at,
388
+ updated_at = excluded.updated_at`
389
+ ).run(
390
+ jobId,
391
+ userId,
392
+ scopedAgentId,
393
+ String(job.sourceType || '').trim() || 'unknown',
394
+ String(job.providerKey || '').trim(),
395
+ Number.isInteger(Number(job.connectionId)) && Number(job.connectionId) > 0 ? Number(job.connectionId) : null,
396
+ String(job.status || 'completed').trim() || 'completed',
397
+ JSON.stringify(parseJsonObject(job.freshnessPolicy, {})),
398
+ JSON.stringify(parseJsonObject(job.cursor, {})),
399
+ JSON.stringify(parseJsonObject(job.summary, {})),
400
+ JSON.stringify(parseJsonObject(job.metadata, {})),
401
+ Number(job.documentCount) || 0,
402
+ String(job.error || '').trim() || null,
403
+ job.startedAt || null,
404
+ job.completedAt || null,
405
+ job.nextSyncAt || null,
406
+ );
407
+ return jobId;
408
+ }
409
+
410
+ upsertIngestionDocument(userId, document = {}, options = {}) {
411
+ const scopedAgentId = this._agentId(userId, options);
412
+ const providerKey = String(document.providerKey || '').trim();
413
+ const connectionId = Number.isInteger(Number(document.connectionId)) && Number(document.connectionId) > 0
414
+ ? Number(document.connectionId)
415
+ : 0;
416
+ const sourceType = String(document.sourceType || '').trim() || 'unknown';
417
+ const externalObjectId = String(document.externalObjectId || '').trim();
418
+ const content = String(document.content || '').trim();
419
+ if (!externalObjectId || !content) {
420
+ throw new Error('Ingestion documents require externalObjectId and content.');
421
+ }
422
+
423
+ const existing = db.prepare(
424
+ `SELECT id, metadata_json
425
+ FROM memory_ingestion_documents
426
+ WHERE user_id = ? AND agent_id = ? AND source_type = ? AND provider_key = ? AND connection_id = ? AND external_object_id = ?`
427
+ ).get(userId, scopedAgentId, sourceType, providerKey, connectionId, externalObjectId);
428
+
429
+ const docId = existing?.id || uuidv4();
430
+ const nextMetadata = {
431
+ ...parseJsonObject(existing?.metadata_json, {}),
432
+ ...parseJsonObject(document.metadata, {}),
433
+ };
434
+
435
+ db.prepare(
436
+ `INSERT INTO memory_ingestion_documents (
437
+ id, user_id, agent_id, source_type, normalized_type, provider_key, connection_id,
438
+ external_object_id, source_account, title, content, summary, salience, source_timestamp,
439
+ metadata_json, payload_json, created_at, updated_at
440
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
441
+ ON CONFLICT(id) DO UPDATE SET
442
+ normalized_type = excluded.normalized_type,
443
+ source_account = excluded.source_account,
444
+ title = excluded.title,
445
+ content = excluded.content,
446
+ summary = excluded.summary,
447
+ salience = excluded.salience,
448
+ source_timestamp = excluded.source_timestamp,
449
+ metadata_json = excluded.metadata_json,
450
+ payload_json = excluded.payload_json,
451
+ updated_at = excluded.updated_at`
452
+ ).run(
453
+ docId,
454
+ userId,
455
+ scopedAgentId,
456
+ sourceType,
457
+ String(document.normalizedType || sourceType).trim() || sourceType,
458
+ providerKey,
459
+ connectionId,
460
+ externalObjectId,
461
+ String(document.sourceAccount || '').trim() || null,
462
+ String(document.title || '').trim() || null,
463
+ content,
464
+ String(document.summary || '').trim() || null,
465
+ Math.max(1, Math.min(10, Number(document.salience) || 5)),
466
+ document.sourceTimestamp || null,
467
+ JSON.stringify(nextMetadata),
468
+ JSON.stringify(parseJsonObject(document.payload, {})),
469
+ );
470
+
471
+ return docId;
472
+ }
473
+
474
+ listIngestionDocuments(userId, { agentId = null, sourceType = null, providerKey = null, limit = 40 } = {}) {
475
+ const scopedAgentId = this._agentId(userId, { agentId });
476
+ let sql = `SELECT *
477
+ FROM memory_ingestion_documents
478
+ WHERE user_id = ? AND agent_id = ?`;
479
+ const params = [userId, scopedAgentId];
480
+ if (sourceType) {
481
+ sql += ' AND source_type = ?';
482
+ params.push(String(sourceType).trim());
483
+ }
484
+ if (providerKey) {
485
+ sql += ' AND provider_key = ?';
486
+ params.push(String(providerKey).trim());
487
+ }
488
+ sql += ' ORDER BY updated_at DESC LIMIT ?';
489
+ params.push(Math.max(1, Math.min(Number(limit) || 40, 200)));
490
+ return db.prepare(sql).all(...params).map((row) => ({
491
+ id: row.id,
492
+ sourceType: row.source_type,
493
+ normalizedType: row.normalized_type,
494
+ providerKey: row.provider_key || null,
495
+ connectionId: row.connection_id ? Number(row.connection_id) : null,
496
+ externalObjectId: row.external_object_id,
497
+ sourceAccount: row.source_account || null,
498
+ title: row.title || null,
499
+ content: row.content,
500
+ summary: row.summary || null,
501
+ salience: Number(row.salience || 0),
502
+ sourceTimestamp: row.source_timestamp || null,
503
+ metadata: parseJsonObject(row.metadata_json, {}),
504
+ payload: parseJsonObject(row.payload_json, {}),
505
+ createdAt: row.created_at,
506
+ updatedAt: row.updated_at,
507
+ }));
508
+ }
509
+
510
+ getIngestionOverview(userId, { agentId = null, limit = 12 } = {}) {
511
+ const jobs = this.listIngestionJobs(userId, { agentId, limit });
512
+ const byProvider = new Map();
513
+ for (const job of jobs) {
514
+ const providerKey = job.providerKey || `local:${job.sourceType}`;
515
+ if (!byProvider.has(providerKey)) {
516
+ byProvider.set(providerKey, {
517
+ providerKey,
518
+ sourceTypes: new Set(),
519
+ status: job.status,
520
+ lastRefreshAt: job.completedAt || job.updatedAt || null,
521
+ nextRefreshAt: job.nextSyncAt || null,
522
+ documentCount: 0,
523
+ error: job.error || null,
524
+ });
525
+ }
526
+ const current = byProvider.get(providerKey);
527
+ current.sourceTypes.add(job.sourceType);
528
+ current.documentCount += job.documentCount;
529
+ if (current.status !== 'failed' && job.status === 'failed') current.status = 'failed';
530
+ if (!current.error && job.error) current.error = job.error;
531
+ }
532
+ return Array.from(byProvider.values()).map((item) => ({
533
+ providerKey: item.providerKey,
534
+ sourceTypes: Array.from(item.sourceTypes),
535
+ status: item.status,
536
+ lastRefreshAt: item.lastRefreshAt,
537
+ nextRefreshAt: item.nextRefreshAt,
538
+ documentCount: item.documentCount,
539
+ error: item.error,
540
+ }));
541
+ }
542
+
543
+ materializeKnowledgeViews(userId, { agentId = null } = {}) {
544
+ const scopedAgentId = this._agentId(userId, { agentId });
545
+ const memories = this.listMemories(userId, { limit: 200, agentId: scopedAgentId });
546
+ const documents = this.listIngestionDocuments(userId, { limit: 200, agentId: scopedAgentId });
547
+ const views = [];
548
+
549
+ const topicGroups = new Map();
550
+ for (const memory of memories) {
551
+ const key = memory.category || 'episodic';
552
+ if (!topicGroups.has(key)) topicGroups.set(key, []);
553
+ topicGroups.get(key).push(memory);
554
+ }
555
+ for (const [topic, items] of topicGroups.entries()) {
556
+ const summary = items.slice(0, 4).map((item) => `- ${item.content}`).join('\n');
557
+ views.push({
558
+ viewType: 'topic',
559
+ subjectKey: topic,
560
+ title: topic.replace(/_/g, ' '),
561
+ summary: summary.slice(0, 320),
562
+ markdownText: `# ${topic}\n\n${summary}`,
563
+ sourceMemoryIds: items.map((item) => item.id),
564
+ sourceDocumentIds: [],
565
+ metadata: {
566
+ itemCount: items.length,
567
+ category: topic,
568
+ },
569
+ });
570
+ }
571
+
572
+ const accountGroups = new Map();
573
+ for (const doc of documents) {
574
+ const accountKey = `${doc.providerKey || 'local'}:${doc.sourceAccount || 'default'}`;
575
+ if (!accountGroups.has(accountKey)) accountGroups.set(accountKey, []);
576
+ accountGroups.get(accountKey).push(doc);
577
+ }
578
+ for (const [accountKey, items] of accountGroups.entries()) {
579
+ const lead = items[0];
580
+ const lines = items.slice(0, 4).map((item) => `- ${item.title || item.normalizedType}: ${item.summary || item.content}`);
581
+ views.push({
582
+ viewType: 'account',
583
+ subjectKey: accountKey,
584
+ title: `${lead.providerKey || 'local'} ${lead.sourceAccount || 'account'}`,
585
+ summary: lines.join('\n').slice(0, 320),
586
+ markdownText: `# ${lead.providerKey || 'local'} / ${lead.sourceAccount || 'account'}\n\n${lines.join('\n')}`,
587
+ sourceMemoryIds: [],
588
+ sourceDocumentIds: items.map((item) => item.id),
589
+ metadata: {
590
+ providerKey: lead.providerKey || null,
591
+ sourceAccount: lead.sourceAccount || null,
592
+ itemCount: items.length,
593
+ },
594
+ });
595
+ }
596
+
597
+ const recentTimeline = [...documents]
598
+ .sort((left, right) => String(right.updatedAt || '').localeCompare(String(left.updatedAt || '')))
599
+ .slice(0, 8);
600
+ if (recentTimeline.length > 0) {
601
+ views.push({
602
+ viewType: 'timeline',
603
+ subjectKey: 'recent',
604
+ title: 'Recent knowledge changes',
605
+ summary: recentTimeline.map((item) => `${item.title || item.normalizedType}: ${item.summary || item.content}`).join(' | ').slice(0, 320),
606
+ markdownText: `# Recent knowledge changes\n\n${recentTimeline.map((item) => `- ${item.title || item.normalizedType}: ${item.summary || item.content}`).join('\n')}`,
607
+ sourceMemoryIds: [],
608
+ sourceDocumentIds: recentTimeline.map((item) => item.id),
609
+ metadata: {
610
+ itemCount: recentTimeline.length,
611
+ },
612
+ });
613
+ }
614
+
615
+ const projectMemories = memories.filter((memory) => memory.category === 'projects');
616
+ for (const memory of projectMemories.slice(0, 12)) {
617
+ views.push({
618
+ viewType: 'project',
619
+ subjectKey: memory.id,
620
+ title: memory.content.split(/[.!?\n]/)[0].slice(0, 120) || 'Project',
621
+ summary: memory.content.slice(0, 320),
622
+ markdownText: `# ${memory.content.split(/[.!?\n]/)[0].slice(0, 120) || 'Project'}\n\n${memory.content}`,
623
+ sourceMemoryIds: [memory.id],
624
+ sourceDocumentIds: [],
625
+ metadata: {
626
+ importance: memory.importance,
627
+ },
628
+ });
629
+ }
630
+
631
+ const personMemories = memories.filter((memory) => ['contacts', 'identity'].includes(memory.category));
632
+ for (const memory of personMemories.slice(0, 12)) {
633
+ views.push({
634
+ viewType: 'person',
635
+ subjectKey: memory.id,
636
+ title: memory.content.split(/[.!?\n]/)[0].slice(0, 120) || 'Person',
637
+ summary: memory.content.slice(0, 320),
638
+ markdownText: `# ${memory.content.split(/[.!?\n]/)[0].slice(0, 120) || 'Person'}\n\n${memory.content}`,
639
+ sourceMemoryIds: [memory.id],
640
+ sourceDocumentIds: [],
641
+ metadata: {
642
+ category: memory.category,
643
+ },
644
+ });
645
+ }
646
+
647
+ for (const view of views) {
648
+ const existing = db.prepare(
649
+ `SELECT id FROM materialized_knowledge_views
650
+ WHERE user_id = ? AND agent_id = ? AND view_type = ? AND subject_key = ?`
651
+ ).get(userId, scopedAgentId, view.viewType, view.subjectKey);
652
+ const viewId = existing?.id || uuidv4();
653
+ db.prepare(
654
+ `INSERT INTO materialized_knowledge_views (
655
+ id, user_id, agent_id, view_type, subject_key, title, summary, markdown_text,
656
+ source_memory_ids_json, source_document_ids_json, metadata_json, created_at, updated_at
657
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
658
+ ON CONFLICT(id) DO UPDATE SET
659
+ title = excluded.title,
660
+ summary = excluded.summary,
661
+ markdown_text = excluded.markdown_text,
662
+ source_memory_ids_json = excluded.source_memory_ids_json,
663
+ source_document_ids_json = excluded.source_document_ids_json,
664
+ metadata_json = excluded.metadata_json,
665
+ updated_at = excluded.updated_at`
666
+ ).run(
667
+ viewId,
668
+ userId,
669
+ scopedAgentId,
670
+ view.viewType,
671
+ view.subjectKey,
672
+ view.title,
673
+ view.summary,
674
+ view.markdownText,
675
+ JSON.stringify(normalizeStringArray(view.sourceMemoryIds, 48, 64)),
676
+ JSON.stringify(normalizeStringArray(view.sourceDocumentIds, 48, 64)),
677
+ JSON.stringify(parseJsonObject(view.metadata, {})),
678
+ );
679
+ }
680
+
681
+ return this.listKnowledgeViews(userId, { agentId: scopedAgentId, limit: 64 });
682
+ }
683
+
684
+ listKnowledgeViews(userId, { agentId = null, viewType = null, limit = 40 } = {}) {
685
+ const scopedAgentId = this._agentId(userId, { agentId });
686
+ let sql = `SELECT *
687
+ FROM materialized_knowledge_views
688
+ WHERE user_id = ? AND agent_id = ?`;
689
+ const params = [userId, scopedAgentId];
690
+ if (viewType) {
691
+ sql += ' AND view_type = ?';
692
+ params.push(String(viewType).trim());
693
+ }
694
+ sql += ' ORDER BY updated_at DESC LIMIT ?';
695
+ params.push(Math.max(1, Math.min(Number(limit) || 40, 100)));
696
+ return db.prepare(sql).all(...params).map((row) => ({
697
+ id: row.id,
698
+ viewType: row.view_type,
699
+ subjectKey: row.subject_key,
700
+ title: row.title,
701
+ summary: row.summary || '',
702
+ markdownText: row.markdown_text || '',
703
+ sourceMemoryIds: normalizeStringArray(parseJsonArray(row.source_memory_ids_json)),
704
+ sourceDocumentIds: normalizeStringArray(parseJsonArray(row.source_document_ids_json)),
705
+ metadata: parseJsonObject(row.metadata_json, {}),
706
+ updatedAt: row.updated_at || null,
707
+ }));
708
+ }
709
+
710
+ listRecentKnowledgeChanges(userId, { agentId = null, limit = 8 } = {}) {
711
+ const docs = this.listIngestionDocuments(userId, { agentId, limit });
712
+ const views = this.listKnowledgeViews(userId, { agentId, limit });
713
+ const changes = [
714
+ ...docs.map((doc) => ({
715
+ kind: 'document',
716
+ id: doc.id,
717
+ title: doc.title || doc.normalizedType,
718
+ summary: doc.summary || doc.content,
719
+ sourceType: doc.sourceType,
720
+ providerKey: doc.providerKey,
721
+ updatedAt: doc.updatedAt,
722
+ })),
723
+ ...views.map((view) => ({
724
+ kind: 'view',
725
+ id: view.id,
726
+ title: view.title,
727
+ summary: view.summary,
728
+ sourceType: view.viewType,
729
+ providerKey: view.metadata?.providerKey || null,
730
+ updatedAt: view.updatedAt,
731
+ })),
732
+ ];
733
+ return changes
734
+ .sort((left, right) => String(right.updatedAt || '').localeCompare(String(left.updatedAt || '')))
735
+ .slice(0, Math.max(1, Math.min(Number(limit) || 8, 24)));
736
+ }
737
+
316
738
  // ─────────────────────────────────────────────────────────────────────────
317
739
  // Semantic Memories (SQLite + embeddings)
318
740
  // ─────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const test = require('node:test');
5
+ const { randomUUID } = require('node:crypto');
6
+
7
+ process.env.OPENAI_API_KEY = '';
8
+ process.env.GOOGLE_AI_KEY = '';
9
+
10
+ const db = require('../../db/database');
11
+ const { resolveAgentId } = require('../agents/manager');
12
+ const { compactTextPayload } = require('../ai/preModelCompaction');
13
+ const { MemoryManager } = require('./manager');
14
+ const { MemoryIngestionService, sourceTypesForConnection } = require('./ingestion');
15
+ const { buildAssistantFocusSnapshot } = require('../widgets/focus_widget');
16
+
17
+ function createTestUser() {
18
+ const username = `openhuman-${randomUUID()}`;
19
+ const result = db.prepare(
20
+ 'INSERT INTO users (username, password) VALUES (?, ?)',
21
+ ).run(username, 'test');
22
+ const userId = Number(result.lastInsertRowid);
23
+ return {
24
+ userId,
25
+ agentId: resolveAgentId(userId, null),
26
+ };
27
+ }
28
+
29
+ function cleanupUser(userId) {
30
+ db.prepare('DELETE FROM users WHERE id = ?').run(userId);
31
+ }
32
+
33
+ test('pre-model compaction strips noisy HTML and records metrics', () => {
34
+ const input = '<html><style>.x{}</style><body>Read https://example.com/a/really/long/path today.<br>Read https://example.com/a/really/long/path today.</body></html>';
35
+ const result = compactTextPayload(input, { maxChars: 120, maxLines: 5 });
36
+
37
+ assert.equal(result.metrics.applied, true);
38
+ assert.ok(result.metrics.strategies.includes('html_to_text'));
39
+ assert.ok(result.metrics.strategies.includes('url_shortening'));
40
+ assert.match(result.text, /example\.com/);
41
+ assert.doesNotMatch(result.text, /<body>/);
42
+ });
43
+
44
+ test('memory ingestion writes typed documents, memory, and materialized views', async () => {
45
+ const { userId, agentId } = createTestUser();
46
+ const memoryManager = new MemoryManager();
47
+ const service = new MemoryIngestionService({ memoryManager, intervalMs: 60_000 });
48
+
49
+ try {
50
+ const result = await service.ingestDocuments(userId, [
51
+ {
52
+ externalObjectId: 'thread-123',
53
+ sourceType: 'email',
54
+ normalizedType: 'email',
55
+ title: 'Launch review',
56
+ content: 'The launch review moved to Friday. Alice owns the deck and Bob owns QA.',
57
+ sourceAccount: 'team@example.com',
58
+ salience: 8,
59
+ },
60
+ ], {
61
+ agentId,
62
+ sourceType: 'email',
63
+ providerKey: 'google_workspace',
64
+ sourceAccount: 'team@example.com',
65
+ });
66
+
67
+ assert.equal(result.status, 'completed');
68
+ assert.equal(result.documentIds.length, 1);
69
+ assert.equal(result.memoryIds.length, 1);
70
+
71
+ const docs = memoryManager.listIngestionDocuments(userId, {
72
+ agentId,
73
+ providerKey: 'google_workspace',
74
+ });
75
+ assert.equal(docs.length, 1);
76
+ assert.equal(docs[0].sourceType, 'email');
77
+ assert.equal(docs[0].sourceAccount, 'team@example.com');
78
+
79
+ const changes = memoryManager.listRecentKnowledgeChanges(userId, { agentId });
80
+ assert.ok(changes.some((change) => change.kind === 'document'));
81
+
82
+ const views = memoryManager.listKnowledgeViews(userId, { agentId });
83
+ assert.ok(views.some((view) => view.viewType === 'timeline'));
84
+ assert.ok(views.some((view) => view.viewType === 'account'));
85
+
86
+ const focus = buildAssistantFocusSnapshot(memoryManager, userId, agentId);
87
+ assert.equal(focus.backgroundAwareness.changedCount > 0, true);
88
+ assert.ok(focus.recentKnowledgeChanges.some((change) => change.title === 'Launch review'));
89
+ } finally {
90
+ cleanupUser(userId);
91
+ }
92
+ });
93
+
94
+ test('integration coverage maps connected apps to durable memory domains', () => {
95
+ assert.deepEqual(sourceTypesForConnection('google_workspace', 'gmail'), ['email']);
96
+ assert.deepEqual(sourceTypesForConnection('github', 'repos'), ['repos', 'tickets']);
97
+ assert.deepEqual(sourceTypesForConnection('spotify', 'spotify'), []);
98
+ });
@@ -52,6 +52,18 @@ function buildAssistantFocusSnapshot(memoryManager, userId, agentId) {
52
52
  ).all(userId, scopedAgentId);
53
53
  const conversations = memoryManager?.getRecentConversations?.(userId, 4, { agentId: scopedAgentId }) || [];
54
54
  const memories = memoryManager?.listMemories?.(userId, { limit: 6, agentId: scopedAgentId }) || [];
55
+ const recentKnowledgeChanges = memoryManager?.listRecentKnowledgeChanges?.(userId, {
56
+ agentId: scopedAgentId,
57
+ limit: 5,
58
+ }) || [];
59
+ const knowledgeViews = memoryManager?.listKnowledgeViews?.(userId, {
60
+ agentId: scopedAgentId,
61
+ limit: 4,
62
+ }) || [];
63
+ const ingestionOverview = memoryManager?.getIngestionOverview?.(userId, {
64
+ agentId: scopedAgentId,
65
+ limit: 8,
66
+ }) || [];
55
67
 
56
68
  const activeThreads = conversations.slice(0, 3).map((conversation) => ({
57
69
  title: safeTrim(conversation.title, 80),
@@ -75,6 +87,24 @@ function buildAssistantFocusSnapshot(memoryManager, userId, agentId) {
75
87
  value: safeTrim(run.status || 'unknown', 32),
76
88
  }));
77
89
  const rememberedContext = memories.slice(0, 2).map((memory) => safeTrim(memory.content, 140));
90
+ const backgroundAwareness = {
91
+ summary: safeTrim(
92
+ recentKnowledgeChanges[0]?.summary
93
+ || recentKnowledgeChanges[0]?.title
94
+ || knowledgeViews[0]?.summary
95
+ || '',
96
+ 180,
97
+ ),
98
+ changedCount: recentKnowledgeChanges.length,
99
+ lastChangedAt: recentKnowledgeChanges[0]?.updatedAt || null,
100
+ };
101
+ const syncHealth = ingestionOverview.slice(0, 3).map((source) => ({
102
+ label: safeTrim(source.providerKey || source.sourceTypes?.[0] || 'Background context', 80),
103
+ value: safeTrim(source.status || 'ready', 32),
104
+ lastRefreshAt: source.lastRefreshAt || null,
105
+ nextRefreshAt: source.nextRefreshAt || null,
106
+ documentCount: Number(source.documentCount || 0),
107
+ }));
78
108
 
79
109
  const currentFocus = safeTrim(
80
110
  selfState.focus?.currentFocus
@@ -90,6 +120,10 @@ function buildAssistantFocusSnapshot(memoryManager, userId, agentId) {
90
120
  nearTermPriorities,
91
121
  recentSignals,
92
122
  rememberedContext,
123
+ recentKnowledgeChanges,
124
+ knowledgeViews,
125
+ backgroundAwareness,
126
+ syncHealth,
93
127
  assistantIdentity: {
94
128
  name: safeTrim(selfState.identity?.displayName, 80),
95
129
  style: safeTrim(selfState.identity?.style, 120),
@@ -99,11 +133,15 @@ function buildAssistantFocusSnapshot(memoryManager, userId, agentId) {
99
133
  }
100
134
 
101
135
  function buildAssistantFocusWidgetPayload(snapshot) {
136
+ const knowledgeChangeCount = Number(snapshot.backgroundAwareness?.changedCount || 0);
137
+ const primaryMetric = knowledgeChangeCount > 0 ? knowledgeChangeCount : snapshot.activeThreads.length;
102
138
  const rows = [
103
139
  ...snapshot.nearTermPriorities,
104
140
  ...snapshot.recentSignals,
141
+ ...snapshot.syncHealth,
105
142
  ].slice(0, 3);
106
143
  const chips = [
144
+ ...snapshot.recentKnowledgeChanges.map((item) => item.title),
107
145
  ...snapshot.activeThreads.map((item) => item.title),
108
146
  ...snapshot.rememberedContext,
109
147
  ].filter(Boolean).slice(0, 3);
@@ -113,14 +151,17 @@ function buildAssistantFocusWidgetPayload(snapshot) {
113
151
  kicker: 'Assistant state',
114
152
  subtitle: snapshot.currentFocus,
115
153
  body: snapshot.activeThreads[0]?.preview
154
+ || snapshot.backgroundAwareness?.summary
116
155
  || snapshot.rememberedContext[0]
117
156
  || 'Watching recent conversations, tasks, and runs.',
118
- metric: String(snapshot.activeThreads.length),
119
- metricLabel: snapshot.activeThreads.length === 1 ? 'active thread' : 'active threads',
157
+ metric: String(primaryMetric),
158
+ metricLabel: knowledgeChangeCount > 0
159
+ ? (knowledgeChangeCount === 1 ? 'knowledge change' : 'knowledge changes')
160
+ : (snapshot.activeThreads.length === 1 ? 'active thread' : 'active threads'),
120
161
  secondaryMetric: String(snapshot.nearTermPriorities.length),
121
162
  secondaryLabel: 'priorities',
122
- tertiaryMetric: formatRelativeTimestamp(snapshot.generatedAt),
123
- tertiaryLabel: 'updated',
163
+ tertiaryMetric: formatRelativeTimestamp(snapshot.backgroundAwareness?.lastChangedAt || snapshot.generatedAt),
164
+ tertiaryLabel: snapshot.backgroundAwareness?.lastChangedAt ? 'last change' : 'updated',
124
165
  rows,
125
166
  chips,
126
167
  iconToken: 'focus',