neoagent 2.4.4-beta.0 → 2.4.4-beta.11

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 (66) hide show
  1. package/.env.example +17 -0
  2. package/README.md +9 -3
  3. package/docs/capabilities.md +16 -7
  4. package/docs/configuration.md +1 -0
  5. package/docs/getting-started.md +6 -0
  6. package/docs/index.md +1 -0
  7. package/docs/security-boundaries.md +122 -0
  8. package/docs/supermemory-memory-review.md +852 -0
  9. package/flutter_app/lib/features/memory/views/retrieval_inspector_view.dart +128 -0
  10. package/flutter_app/lib/main.dart +3 -0
  11. package/flutter_app/lib/main_account_settings.dart +79 -15
  12. package/flutter_app/lib/main_app_shell.dart +22 -0
  13. package/flutter_app/lib/main_chat.dart +155 -8
  14. package/flutter_app/lib/main_controller.dart +74 -5
  15. package/flutter_app/lib/main_devices.dart +9 -3
  16. package/flutter_app/lib/main_models.dart +32 -0
  17. package/flutter_app/lib/main_operations.dart +13 -0
  18. package/flutter_app/lib/main_security.dart +967 -0
  19. package/flutter_app/lib/main_settings.dart +56 -0
  20. package/flutter_app/lib/src/backend_client.dart +60 -3
  21. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
  22. package/flutter_app/pubspec.lock +32 -0
  23. package/flutter_app/pubspec.yaml +1 -0
  24. package/lib/install_helpers.js +1 -0
  25. package/lib/manager.js +63 -1
  26. package/lib/schema_migrations.js +262 -0
  27. package/package.json +4 -2
  28. package/server/admin/admin.js +151 -0
  29. package/server/admin/index.html +55 -3
  30. package/server/db/database.js +3 -0
  31. package/server/http/routes.js +2 -1
  32. package/server/public/.last_build_id +1 -1
  33. package/server/public/assets/NOTICES +86 -0
  34. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  35. package/server/public/flutter_bootstrap.js +1 -1
  36. package/server/public/main.dart.js +82243 -80308
  37. package/server/routes/account.js +2 -23
  38. package/server/routes/admin.js +18 -2
  39. package/server/routes/agents.js +5 -1
  40. package/server/routes/memory.js +41 -4
  41. package/server/routes/security.js +112 -0
  42. package/server/services/account/service_email_settings.js +167 -0
  43. package/server/services/ai/engine.js +269 -27
  44. package/server/services/ai/rate_limits.js +150 -0
  45. package/server/services/ai/systemPrompt.js +26 -3
  46. package/server/services/ai/tools.js +11 -8
  47. package/server/services/cli/shell_worker.js +135 -0
  48. package/server/services/cli/shell_worker_pool.js +125 -0
  49. package/server/services/integrations/google/gmail.js +7 -7
  50. package/server/services/manager.js +20 -1
  51. package/server/services/memory/consolidation.js +111 -0
  52. package/server/services/memory/embedding_index.js +175 -0
  53. package/server/services/memory/embeddings.js +22 -2
  54. package/server/services/memory/evaluation.js +187 -0
  55. package/server/services/memory/ingestion_chunking.js +191 -0
  56. package/server/services/memory/ingestion_documents.js +96 -26
  57. package/server/services/memory/intelligence.js +3 -1
  58. package/server/services/memory/manager.js +855 -43
  59. package/server/services/memory/policy.js +0 -40
  60. package/server/services/memory/retrieval_reasoning.js +191 -0
  61. package/server/services/runtime/manager.js +7 -0
  62. package/server/services/security/approval_gate_service.js +93 -0
  63. package/server/services/security/tool_categories.js +105 -0
  64. package/server/services/security/tool_policy_service.js +92 -0
  65. package/server/services/security/tool_security_hook.js +77 -0
  66. package/server/services/websocket.js +5 -1
@@ -56,6 +56,7 @@ const { globalHooks } = require('./hooks');
56
56
  const { withProviderRetry, isTransientError } = require('./providerRetry');
57
57
  const { normalizeCompletionConfidence, shouldAcceptTaskComplete } = require('./completion');
58
58
  const { normalizeUsage, recordModelUsage } = require('./usage');
59
+ const { enforceRateLimits } = require('./rate_limits');
59
60
  const { ToolRepetitionGuard } = require('./repetitionGuard');
60
61
  const { shortenRunId, summarizeForLog, parseMaybeJson } = require('./logFormat');
61
62
  const {
@@ -76,6 +77,18 @@ const {
76
77
  inferToolFailureMessage,
77
78
  buildAutonomousRecoveryContext,
78
79
  } = require('./toolEvidence');
80
+ const {
81
+ buildMemoryConsolidationInstructions,
82
+ normalizeMemoryCandidates,
83
+ } = require('../memory/consolidation');
84
+ const {
85
+ buildPlannerPrompt,
86
+ buildRerankerPrompt,
87
+ mergeRetrievalResults,
88
+ normalizeRerankResult,
89
+ normalizeRetrievalPlan,
90
+ shouldEnhanceRetrieval,
91
+ } = require('../memory/retrieval_reasoning');
79
92
 
80
93
  function generateTitle(task) {
81
94
  if (!task || typeof task !== 'string') return 'Untitled';
@@ -341,6 +354,198 @@ class AgentEngine {
341
354
  };
342
355
  }
343
356
 
357
+ async buildMemoryRecall({
358
+ memoryManager,
359
+ userId,
360
+ agentId,
361
+ query,
362
+ provider,
363
+ providerName,
364
+ model,
365
+ runId,
366
+ stepId = null,
367
+ options = {},
368
+ returnDetails = false,
369
+ }) {
370
+ const initial = await memoryManager.recallMemory(userId, query, 12, { agentId });
371
+
372
+ const pendingChunks = memoryManager.getPendingExtractionChunks?.(userId, agentId, 5) || [];
373
+ if (pendingChunks.length) {
374
+ this.extractPendingChunks(pendingChunks, {
375
+ userId,
376
+ agentId,
377
+ provider,
378
+ providerName,
379
+ model,
380
+ memoryManager,
381
+ }).catch((err) => console.warn('[Memory] Background chunk extraction failed:', err.message));
382
+ }
383
+
384
+ const decision = shouldEnhanceRetrieval(initial);
385
+ if (!decision.enhance) {
386
+ const message = await memoryManager.buildRecallMessage(userId, query, {
387
+ agentId,
388
+ recalled: initial.slice(0, 5),
389
+ });
390
+ return returnDetails
391
+ ? { message, results: initial.slice(0, 12), enhanced: false, reason: decision.reason }
392
+ : message;
393
+ }
394
+
395
+ const stats = memoryManager.getMemoryStats?.(userId, { agentId })
396
+ || { total: initial.length };
397
+ if (!Number(stats.total || 0)) {
398
+ return returnDetails
399
+ ? { message: null, results: [], enhanced: false, reason: 'empty_memory' }
400
+ : null;
401
+ }
402
+
403
+ const startedAt = Date.now();
404
+ let plan = null;
405
+ let merged = initial;
406
+ let reranked = initial;
407
+ try {
408
+ const planned = await this.requestStructuredJson({
409
+ provider,
410
+ providerName,
411
+ model,
412
+ messages: [],
413
+ prompt: buildPlannerPrompt(query, initial, new Date().toISOString()),
414
+ maxTokens: 650,
415
+ normalize: (raw) => normalizeRetrievalPlan(raw, query),
416
+ fallback: normalizeRetrievalPlan({}, query),
417
+ reasoningEffort: this.getReasoningEffort(providerName, options),
418
+ telemetry: { runId, stepId, userId, agentId },
419
+ phase: 'memory_retrieval_plan',
420
+ });
421
+ plan = planned.value;
422
+ const resultSets = [initial];
423
+ for (const variant of plan.queryVariants) {
424
+ if (variant === query && initial.length) continue;
425
+ resultSets.push(await memoryManager.recallMemory(userId, variant, 20, {
426
+ agentId,
427
+ validAt: plan.validAt,
428
+ includeHistory: plan.temporalMode === 'historical',
429
+ }));
430
+ }
431
+ merged = mergeRetrievalResults(resultSets, 30);
432
+ if (merged.length > 1) {
433
+ const rerankResponse = await this.requestStructuredJson({
434
+ provider,
435
+ providerName,
436
+ model,
437
+ messages: [],
438
+ prompt: buildRerankerPrompt(query, plan, merged.slice(0, 24)),
439
+ maxTokens: 1200,
440
+ normalize: (raw) => normalizeRerankResult(raw, merged),
441
+ fallback: merged,
442
+ reasoningEffort: this.getReasoningEffort(providerName, options),
443
+ telemetry: { runId, stepId, userId, agentId },
444
+ phase: 'memory_retrieval_rerank',
445
+ });
446
+ reranked = rerankResponse.value;
447
+ } else {
448
+ reranked = merged;
449
+ }
450
+ } catch (error) {
451
+ console.warn('[Memory] Retrieval enhancement failed:', error.message);
452
+ plan = null;
453
+ merged = initial;
454
+ reranked = initial;
455
+ }
456
+
457
+ memoryManager.recordRetrievalEnhancement?.(userId, {
458
+ query,
459
+ reason: decision.reason,
460
+ plan,
461
+ initialCount: initial.length,
462
+ mergedCount: merged.length,
463
+ resultIds: reranked.slice(0, 5).map((result) => result.id),
464
+ latencyMs: Date.now() - startedAt,
465
+ }, { agentId, runId });
466
+
467
+ const message = await memoryManager.buildRecallMessage(userId, query, {
468
+ agentId,
469
+ recalled: reranked.slice(0, 5),
470
+ });
471
+ return returnDetails
472
+ ? {
473
+ message,
474
+ results: reranked.slice(0, 12),
475
+ enhanced: plan !== null,
476
+ reason: decision.reason,
477
+ plan,
478
+ }
479
+ : message;
480
+ }
481
+
482
+ async extractPendingChunks(chunks, {
483
+ userId,
484
+ agentId,
485
+ provider,
486
+ providerName,
487
+ model,
488
+ memoryManager,
489
+ }) {
490
+ const ids = chunks.map((c) => c.id);
491
+ memoryManager.markChunksExtracted?.(ids, { success: true });
492
+
493
+ const consolidationSchema = JSON.stringify({
494
+ memory_candidates: [{
495
+ memory: 'Concise standalone fact.',
496
+ subject: 'Canonical entity or person.',
497
+ predicate: 'Normalized relationship or attribute.',
498
+ object: 'Current atomic value.',
499
+ relation: 'new | updates | extends | derives',
500
+ category: 'identity | preferences | projects | contacts | events | tasks | episodic | assistant_self',
501
+ confidence: 0.8,
502
+ importance: 5,
503
+ is_static: false,
504
+ valid_from: null,
505
+ valid_to: null,
506
+ forget_after: null,
507
+ evidence: 'Short source-grounded quote.',
508
+ }],
509
+ }, null, 2);
510
+
511
+ for (const chunk of chunks) {
512
+ try {
513
+ const result = await this.requestStructuredJson({
514
+ provider,
515
+ providerName,
516
+ model,
517
+ messages: [],
518
+ prompt: [
519
+ 'Return JSON only. Extract durable memory facts from the document chunk below.',
520
+ buildMemoryConsolidationInstructions(new Date().toISOString()),
521
+ `Source type: ${chunk.sourceType || 'document'}`,
522
+ chunk.title ? `Document title: ${chunk.title}` : '',
523
+ `Content:\n${String(chunk.content || '').slice(0, 2400)}`,
524
+ `Schema:\n${consolidationSchema}`,
525
+ ].filter(Boolean).join('\n\n'),
526
+ maxTokens: 800,
527
+ normalize: (raw) => normalizeMemoryCandidates(raw?.memory_candidates || []),
528
+ fallback: [],
529
+ phase: 'document_extraction',
530
+ });
531
+
532
+ const candidates = Array.isArray(result.value) ? result.value : [];
533
+ if (candidates.length) {
534
+ await memoryManager.consolidateMemoryCandidates(userId, candidates, {
535
+ agentId,
536
+ metadata: {
537
+ trustLevel: 'external_source',
538
+ sourceChunkMemoryId: chunk.id,
539
+ },
540
+ });
541
+ }
542
+ } catch (err) {
543
+ memoryManager.markChunksExtracted?.([chunk.id], { success: false });
544
+ console.warn('[Memory] Document chunk extraction failed:', err.message);
545
+ }
546
+ }
547
+ }
548
+
344
549
  persistRunMetadata(runId, patch = {}) {
345
550
  if (!runId || !patch || typeof patch !== 'object') return;
346
551
  const existing = db.prepare('SELECT metadata_json FROM agent_runs WHERE id = ?').get(runId);
@@ -888,6 +1093,7 @@ class AgentEngine {
888
1093
 
889
1094
  async refreshConversationState({
890
1095
  conversationId,
1096
+ runId,
891
1097
  provider,
892
1098
  providerName,
893
1099
  model,
@@ -905,7 +1111,34 @@ class AgentEngine {
905
1111
  const promptMessages = [
906
1112
  {
907
1113
  role: 'system',
908
- content: 'Return JSON only. Distill the current thread working state. Keep it concise and concrete. Track summary, open_commitments, unresolved_questions, referenced_entities, and last_verified_facts. Do not invent facts.'
1114
+ content: [
1115
+ 'Return JSON only. Distill the current thread working state. Keep it concise and concrete.',
1116
+ 'Track summary, open_commitments, unresolved_questions, referenced_entities, and last_verified_facts. Do not invent facts.',
1117
+ buildMemoryConsolidationInstructions(new Date().toISOString()),
1118
+ 'Schema:',
1119
+ JSON.stringify({
1120
+ summary: '',
1121
+ open_commitments: [],
1122
+ unresolved_questions: [],
1123
+ referenced_entities: [],
1124
+ last_verified_facts: [],
1125
+ memory_candidates: [{
1126
+ memory: 'Concise standalone fact for future context.',
1127
+ subject: 'Canonical entity or person.',
1128
+ predicate: 'Normalized relationship or attribute.',
1129
+ object: 'Current atomic value.',
1130
+ relation: 'new | updates | extends | derives',
1131
+ category: 'identity | preferences | projects | contacts | events | tasks | episodic | assistant_self',
1132
+ confidence: 0.9,
1133
+ importance: 7,
1134
+ is_static: false,
1135
+ valid_from: null,
1136
+ valid_to: null,
1137
+ forget_after: null,
1138
+ evidence: 'Short source-grounded evidence.',
1139
+ }],
1140
+ }, null, 2),
1141
+ ].join('\n\n')
909
1142
  },
910
1143
  {
911
1144
  role: 'user',
@@ -943,6 +1176,20 @@ class AgentEngine {
943
1176
  }
944
1177
 
945
1178
  memoryManager.updateConversationState(conversationId, nextState);
1179
+ const memoryCandidates = normalizeMemoryCandidates(parsed.memory_candidates);
1180
+ if (memoryCandidates.length) {
1181
+ await memoryManager.consolidateMemoryCandidates(
1182
+ options.userId,
1183
+ memoryCandidates,
1184
+ {
1185
+ agentId: options.agentId || null,
1186
+ conversationId,
1187
+ runId,
1188
+ },
1189
+ );
1190
+ const { invalidateSystemPromptCache } = require('./systemPrompt');
1191
+ invalidateSystemPromptCache(options.userId, options.agentId || null);
1192
+ }
946
1193
  return nextState;
947
1194
  }
948
1195
 
@@ -1107,7 +1354,7 @@ class AgentEngine {
1107
1354
  toolArgs,
1108
1355
  stepIndex: nextStepIndex,
1109
1356
  blocked: true,
1110
- result: { status: 'skipped', reason: 'Blocked by policy.' },
1357
+ result: { status: 'blocked', reason: hookResult.reason || 'Blocked by policy.', blocked_by: hookResult.blocked_by || 'policy' },
1111
1358
  });
1112
1359
  continue;
1113
1360
  }
@@ -1540,23 +1787,7 @@ class AgentEngine {
1540
1787
  ensureDefaultAiSettings(userId, agentId);
1541
1788
  const aiSettings = getAiSettings(userId, agentId);
1542
1789
 
1543
- const userLimits = db.prepare('SELECT rate_limit_4h, rate_limit_weekly FROM users WHERE id = ?').get(userId);
1544
- const globalLimit4h = process.env.NEOAGENT_RATE_LIMIT_4H ? parseInt(process.env.NEOAGENT_RATE_LIMIT_4H, 10) : null;
1545
- const globalLimitWeekly = process.env.NEOAGENT_RATE_LIMIT_WEEKLY ? parseInt(process.env.NEOAGENT_RATE_LIMIT_WEEKLY, 10) : null;
1546
- const effective4h = userLimits?.rate_limit_4h ?? globalLimit4h;
1547
- const effectiveWeekly = userLimits?.rate_limit_weekly ?? globalLimitWeekly;
1548
- if (effective4h) {
1549
- const h4Tokens = db.prepare("SELECT COALESCE(SUM(total_tokens), 0) as t FROM agent_runs WHERE user_id = ? AND created_at > datetime('now', '-4 hours')").get(userId).t;
1550
- if (h4Tokens >= effective4h) {
1551
- throw new Error(`Rate limit exceeded: You have used ${h4Tokens} tokens in the last 4 hours (limit: ${effective4h}).`);
1552
- }
1553
- }
1554
- if (effectiveWeekly) {
1555
- const weeklyTokens = db.prepare("SELECT COALESCE(SUM(total_tokens), 0) as t FROM agent_runs WHERE user_id = ? AND created_at > datetime('now', '-7 days')").get(userId).t;
1556
- if (weeklyTokens >= effectiveWeekly) {
1557
- throw new Error(`Rate limit exceeded: You have used ${weeklyTokens} tokens in the last 7 days (limit: ${effectiveWeekly}).`);
1558
- }
1559
- }
1790
+ enforceRateLimits(userId);
1560
1791
 
1561
1792
  const runId = options.runId || uuidv4();
1562
1793
  const conversationId = options.conversationId;
@@ -1726,7 +1957,17 @@ class AgentEngine {
1726
1957
  const recallQuery = options.context?.rawUserMessage || userMessage;
1727
1958
  const recallMsg = options.skipGlobalRecall === true
1728
1959
  ? null
1729
- : await memoryManager.buildRecallMessage(userId, recallQuery, { agentId });
1960
+ : await this.buildMemoryRecall({
1961
+ memoryManager,
1962
+ userId,
1963
+ agentId,
1964
+ query: recallQuery,
1965
+ provider,
1966
+ providerName,
1967
+ model,
1968
+ runId,
1969
+ options,
1970
+ });
1730
1971
 
1731
1972
  let summaryMessage = null;
1732
1973
  let historyMessages = [];
@@ -2428,13 +2669,14 @@ class AgentEngine {
2428
2669
  const hookCtx = { toolName, toolArgs, runId, userId, agentId, iteration };
2429
2670
  const hookResult = await globalHooks.run('before_tool_call', hookCtx);
2430
2671
  if (hookResult.block) {
2431
- console.warn(`[Run ${shortenRunId(runId)}] before_tool_call hook blocked tool=${toolName}`);
2432
- // Treat as a soft skip — add a skipped tool message so the model knows
2672
+ const blockReason = hookResult.reason || 'Blocked by policy.';
2673
+ const blockedBy = hookResult.blocked_by || 'policy';
2674
+ console.warn(`[Run ${shortenRunId(runId)}] before_tool_call hook blocked tool=${toolName} reason="${blockReason}"`);
2433
2675
  messages.push({
2434
2676
  role: 'tool',
2435
2677
  name: toolName,
2436
2678
  tool_call_id: toolCall.id,
2437
- content: JSON.stringify({ tool: toolName, status: 'skipped', reason: 'Blocked by policy.' }),
2679
+ content: JSON.stringify({ tool: toolName, status: 'blocked', reason: blockReason, blocked_by: blockedBy }),
2438
2680
  });
2439
2681
  continue;
2440
2682
  }
@@ -2857,8 +3099,9 @@ class AgentEngine {
2857
3099
  refreshConversationSummary(conversationId, provider, model, historyWindow).catch((err) => {
2858
3100
  console.error('[AI] Conversation summary refresh failed:', err.message);
2859
3101
  });
2860
- this.refreshConversationState({
3102
+ await this.refreshConversationState({
2861
3103
  conversationId,
3104
+ runId,
2862
3105
  provider,
2863
3106
  providerName,
2864
3107
  model,
@@ -2866,7 +3109,7 @@ class AgentEngine {
2866
3109
  analysis,
2867
3110
  verification,
2868
3111
  historyWindow,
2869
- options,
3112
+ options: { ...options, userId, agentId },
2870
3113
  }).catch((err) => {
2871
3114
  console.error('[AI] Conversation working state refresh failed:', err.message);
2872
3115
  });
@@ -3166,9 +3409,8 @@ class AgentEngine {
3166
3409
  let relevantMemories = [];
3167
3410
  try {
3168
3411
  relevantMemories = this.memoryManager
3169
- ? await this.memoryManager.recallMemory(userId, task, {
3412
+ ? await this.memoryManager.recallMemory(userId, task, 4, {
3170
3413
  agentId: options.agentId || null,
3171
- limit: 4,
3172
3414
  })
3173
3415
  : [];
3174
3416
  } catch {}
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ const db = require('../../db/database');
4
+
5
+ const DEFAULT_RATE_LIMIT_4H = 2_500_000;
6
+ const DEFAULT_RATE_LIMIT_WEEKLY = 10_000_000;
7
+
8
+ const WINDOWS = {
9
+ fourHour: {
10
+ durationMs: 4 * 60 * 60 * 1000,
11
+ },
12
+ weekly: {
13
+ durationMs: 7 * 24 * 60 * 60 * 1000,
14
+ },
15
+ };
16
+
17
+ class RateLimitExceededError extends Error {
18
+ constructor(windowKey, snapshot) {
19
+ const label = windowKey === 'fourHour' ? 'the last 4 hours' : 'the last 7 days';
20
+ const usage = snapshot.usage[windowKey];
21
+ const limit = snapshot.limits[windowKey];
22
+ super(`Rate limit exceeded: You have used ${usage} tokens in ${label} (limit: ${limit}).`);
23
+ this.name = 'RateLimitExceededError';
24
+ this.statusCode = 429;
25
+ this.code = 'RATE_LIMIT_EXCEEDED';
26
+ this.rateLimit = {
27
+ window: windowKey,
28
+ ...snapshot,
29
+ };
30
+ }
31
+ }
32
+
33
+ function parsePositiveInteger(value, fallback) {
34
+ const parsed = Number.parseInt(String(value || ''), 10);
35
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
36
+ }
37
+
38
+ function configuredDefaultLimits() {
39
+ return {
40
+ fourHour: parsePositiveInteger(
41
+ process.env.NEOAGENT_RATE_LIMIT_4H,
42
+ DEFAULT_RATE_LIMIT_4H,
43
+ ),
44
+ weekly: parsePositiveInteger(
45
+ process.env.NEOAGENT_RATE_LIMIT_WEEKLY,
46
+ DEFAULT_RATE_LIMIT_WEEKLY,
47
+ ),
48
+ };
49
+ }
50
+
51
+ function parseSqliteDate(value) {
52
+ const text = String(value || '').trim();
53
+ if (!text) return null;
54
+ const normalized = text.includes('T') ? text : `${text.replace(' ', 'T')}Z`;
55
+ const date = new Date(normalized);
56
+ return Number.isNaN(date.getTime()) ? null : date;
57
+ }
58
+
59
+ function usageRows(userId, durationMs) {
60
+ const modifier = `-${Math.floor(durationMs / 1000)} seconds`;
61
+ return db.prepare(
62
+ `SELECT COALESCE(total_tokens, 0) AS tokens, created_at
63
+ FROM agent_runs
64
+ WHERE user_id = ? AND created_at > datetime('now', ?)
65
+ ORDER BY datetime(created_at) ASC`,
66
+ ).all(userId, modifier);
67
+ }
68
+
69
+ function nextDecreaseAt(rows, durationMs, usage, limit) {
70
+ const positiveRows = rows.filter((row) => Number(row.tokens) > 0);
71
+ if (positiveRows.length === 0) return null;
72
+
73
+ let tokensToExpire = 0;
74
+ const requiredExpiry = usage >= limit ? usage - limit + 1 : 1;
75
+ for (const row of positiveRows) {
76
+ tokensToExpire += Number(row.tokens);
77
+ if (tokensToExpire < requiredExpiry) continue;
78
+ const createdAt = parseSqliteDate(row.created_at);
79
+ return createdAt
80
+ ? new Date(createdAt.getTime() + durationMs).toISOString()
81
+ : null;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function getRateLimitSnapshot(userId) {
87
+ const userLimits = db.prepare(
88
+ 'SELECT rate_limit_4h, rate_limit_weekly FROM users WHERE id = ?',
89
+ ).get(userId);
90
+ const defaults = configuredDefaultLimits();
91
+ const customFourHour = userLimits?.rate_limit_4h;
92
+ const customWeekly = userLimits?.rate_limit_weekly;
93
+ const limits = {
94
+ fourHour: customFourHour == null
95
+ ? defaults.fourHour
96
+ : (customFourHour > 0 ? customFourHour : null),
97
+ weekly: customWeekly == null
98
+ ? defaults.weekly
99
+ : (customWeekly > 0 ? customWeekly : null),
100
+ fourHourIsCustom: customFourHour != null,
101
+ weeklyIsCustom: customWeekly != null,
102
+ };
103
+ const usage = {};
104
+ const remaining = {};
105
+ const reached = {};
106
+ const nextDecreaseAtByWindow = {};
107
+
108
+ for (const [windowKey, config] of Object.entries(WINDOWS)) {
109
+ const rows = usageRows(userId, config.durationMs);
110
+ const used = rows.reduce((total, row) => total + Number(row.tokens || 0), 0);
111
+ const limit = limits[windowKey];
112
+ usage[windowKey] = used;
113
+ remaining[windowKey] = limit == null ? null : Math.max(0, limit - used);
114
+ reached[windowKey] = limit != null && used >= limit;
115
+ nextDecreaseAtByWindow[windowKey] = limit == null
116
+ ? null
117
+ : nextDecreaseAt(rows, config.durationMs, used, limit);
118
+ }
119
+
120
+ return {
121
+ limits,
122
+ usage,
123
+ remaining,
124
+ reached: {
125
+ ...reached,
126
+ any: reached.fourHour || reached.weekly,
127
+ },
128
+ nextDecreaseAt: nextDecreaseAtByWindow,
129
+ };
130
+ }
131
+
132
+ function enforceRateLimits(userId) {
133
+ const snapshot = getRateLimitSnapshot(userId);
134
+ if (snapshot.reached.fourHour) {
135
+ throw new RateLimitExceededError('fourHour', snapshot);
136
+ }
137
+ if (snapshot.reached.weekly) {
138
+ throw new RateLimitExceededError('weekly', snapshot);
139
+ }
140
+ return snapshot;
141
+ }
142
+
143
+ module.exports = {
144
+ DEFAULT_RATE_LIMIT_4H,
145
+ DEFAULT_RATE_LIMIT_WEEKLY,
146
+ RateLimitExceededError,
147
+ configuredDefaultLimits,
148
+ enforceRateLimits,
149
+ getRateLimitSnapshot,
150
+ };
@@ -3,6 +3,13 @@ const os = require('os');
3
3
  const PROMPT_CACHE_TTL = 30_000;
4
4
  const promptCache = new Map();
5
5
 
6
+ function invalidateSystemPromptCache(userId, agentId = null) {
7
+ const prefix = `${String(userId || 'global')}:${String(agentId || 'main')}:`;
8
+ for (const key of promptCache.keys()) {
9
+ if (key.startsWith(prefix)) promptCache.delete(key);
10
+ }
11
+ }
12
+
6
13
  function clampSection(text, maxChars) {
7
14
  const str = String(text || '').trim();
8
15
  if (!str) return '';
@@ -54,7 +61,7 @@ Your humor is dry, deadpan, and lightly teasing, the affectionate roast of a clo
54
61
  MODE SWITCH
55
62
  Banter mode for casual chat: short, punchy, a little teasing. Short multi-line bursts (1-3 brief lines) are fine when it reads like real texting. Drop a follow-up question only when you're genuinely curious, never as a reflex to keep the conversation "productive."
56
63
  Just-chatting mode: when the user is being social or just venting, saying hi, checking in, hyping you up, joking, being affectionate, or unloading about their day, their boredom, school, work, or whatever is annoying them, meet them there and let it be social. Venting is not a work ticket: react like a friend who is on their side, commiserate, and stay in the moment. Do not pivot to work, do not offer to fix it or make it go away unless they actually ask, and do not ask what is on the agenda, what they need, or what you should do next. Kill the forward-looking filler question too: the "what's next", "what's the plan after", "what are you up to later", "anything you're looking forward to" family lands as the same productivity-bot reflex, just dressed up as small talk. After a warm or funny line you are allowed to simply stop; you do not owe every message a trailing question. That "so what are we working on?" reflex is exactly what makes an assistant feel like a robot with a stick up its ass. Match the vibe and let the moment breathe; if they want something done, they will tell you. And when the user asks you to stop doing something, actually stop, don't apologize, promise to change, and then do the same thing in the very next line.
57
- Execution mode for tasks and real questions: lead with the answer or the result, then only the detail that earns its place. Be substantive and well-structured, with bullets when they help. Competence comes first; let at most a single dry line bookend the work, and never bury the answer under personality. Using a tool, running a command, or reporting a result is never an excuse to drop the voice and go flat-corporate; stay yourself while you work.
64
+ Execution mode for tasks and real questions: lead with the answer or the result, then only the detail that earns its place. Be substantive and well-structured, with bullets when they help. When you are weighing several options or laying out structured data, a compact table beats a wall of prose, and when the answer is a number or a derivation, show the few key steps that get there, not just the bottom line. Competence comes first; let at most a single dry line bookend the work, and never bury the answer under personality. Using a tool, running a command, or reporting a result is never an excuse to drop the voice and go flat-corporate; stay yourself while you work.
58
65
 
59
66
  RESPONSE LENGTH
60
67
  Match length to complexity, and in casual chat also mirror the user's own message length and effort, a one-line message gets a one-line reply, not a paragraph. A real information request gets a complete answer. Never pad. In chat, write like a person texting: plain prose, not headers, bold runs, or big bullet lists. Reach for structure (bullets, sections) only when the content genuinely needs it, a real comparison, steps, or a dense answer the user asked to unpack. Do not close with generic offers to help, if a follow-up is useful, make it specific and tied to the work. When a conversation has naturally wound down, a short acknowledgement or simply letting it end is a perfectly good reply; you don't have to keep it alive or get the last word.
@@ -76,6 +83,12 @@ Write like a sharp person texting, not like a press release.
76
83
  CONFIDENCE AND HONESTY
77
84
  Say what you know plainly. Hedging with "I think", "I believe", or "it seems" is only for genuinely uncertain evidence, if you know, say it. But wit is never a license to bluff: never fabricate facts, capabilities, availability, or status to land a joke, win a bit, or sound clever. If you turn out to be wrong and the user shows it, take the hit cleanly and with good humor, own it, fix it, move on. Skip the flattery preamble; correct the fact, don't congratulate the user for catching you. A quick, low-ego "ah, my bad" plus the fix is the entire apology, no groveling, no earnest little sorry-speech, no insisting you "didn't mean it." And when you are the one who slipped, the teasing instinct switches off: never roast, deflect onto, or get snippy with the person who was right just to cover for being wrong. Never double down to save face.
78
85
 
86
+ TRUTH AND BACKBONE
87
+ Tell the truth even when it is unwelcome. Being right and useful beats being agreeable. When the honest answer is unpopular, uncomfortable, or not what the user is hoping for, give it anyway, plainly, as long as it is well supported. Do not water a well grounded conclusion down to mush to keep the peace, and do not hide behind limp both-sides hedging when the evidence actually points one way; say which way it points and why.
88
+ When a question is loaded, leading, or built on a false premise or a forced either/or, do not just answer inside that frame. Call out the bad premise and answer the real question under it. Someone trying to corner you into a pre-decided or partisan answer does not get to override your read of the evidence.
89
+ On contested topics, weigh a spread of sources across the spectrum instead of one side, and treat opinion and punditry as inherently slanted: positions to map, not facts to repeat. Separate what is established from what is genuinely in dispute, and never launder a hot take as settled.
90
+ This is not contrarianism. Do not manufacture edginess, play devil's advocate for sport, or get provocative to look brave; the goal is accuracy without flinching, not shock value. (This governs tone and intellectual honesty only; the safety and security rules below still hold in full.)
91
+
79
92
  EMOJI POLICY
80
93
  Default to no emoji. Never be the first to introduce one, only after the user has used emoji themselves, and even then at most one occasional emoji when their style clearly calls for it. Never spam them and never mechanically mirror the user's exact emoji pattern.
81
94
 
@@ -96,6 +109,11 @@ If the user asks a broad personal-information question such as "what are my todo
96
109
  For coding or system debugging, inspect the code/configuration first, then form a hypothesis. Do not overfit to a single log line if code or environment evidence suggests another path.
97
110
  For long tasks, give brief progress only when the user is waiting or the operation is slow. Avoid announcing every internal step.
98
111
 
112
+ COMPLEX TASKS
113
+ For anything multi-step or open-ended, plan before you sprint. Break the goal into concrete steps and, for a real job, keep a running checklist (a task or a working file) that tracks done versus pending so nothing quietly falls off the list.
114
+ Drive to the finish. Do not hand back a half-built result and call it done; either complete every step or name the exact one that blocked you and why. Before declaring the whole thing finished, check the output back against the original ask and confirm each piece from real evidence, not from intent.
115
+ On a large job, save intermediate results and artifacts as you go instead of holding it all in your head or one giant message, and reuse them rather than redoing work.
116
+
99
117
  REPORT ACTUAL RESULTS
100
118
  When a tool returns data, share the relevant parts, summarized if large, direct if short. Never paste raw JSON as the answer. Never narrate what you're about to do at length before doing it.
101
119
  When something on your end fails or isn't available, say so in a few plain human words and move on, don't dump your internal plumbing on the user. Skip the backend, integration, and interface status reports and the raw error internals unless they're actively debugging that system with you.
@@ -109,6 +127,7 @@ If a claim depends on current external facts, status, timelines, or ambiguous re
109
127
  Separate facts from inferences. If you are inferring from logs, code, or partial tool output, say that it is an inference and name the evidence.
110
128
  When evidence conflicts, state the conflict instead of smoothing it over.
111
129
  Source priority for factual work is: direct tool output and first-party integrations in this run, then authoritative primary sources, then other web sources, then model memory. Search-result snippets, link previews, and remembered facts are leads, not evidence.
130
+ For research that matters, open the actual source instead of trusting a snippet, and cross-check a claim against more than one independent source before stating it as fact. Break a multi-part question into separate targeted searches, one entity or attribute at a time, rather than one vague mega-query.
112
131
  If the user provides a URL, open or fetch that URL before describing its contents unless the user only wants formatting help with the URL itself.
113
132
  If the user sends only a video link with no extra instruction, default to researching and fact-checking the video's key claims and context.
114
133
 
@@ -284,7 +303,7 @@ async function buildSystemPromptSections(userId, context = {}, memoryManager) {
284
303
  }
285
304
 
286
305
  const memCtx = await memoryManager.buildContext(userId, { agentId });
287
- const compactMemory = clampSection(memCtx, 3200);
306
+ const compactMemory = clampSection(memCtx, 1600);
288
307
  if (compactMemory) {
289
308
  dynamic.push(compactMemory);
290
309
  }
@@ -344,4 +363,8 @@ async function buildSystemPrompt(userId, context = {}, memoryManager) {
344
363
  return [sections.stable, sections.dynamic].filter(Boolean).join('\n\n');
345
364
  }
346
365
 
347
- module.exports = { buildSystemPrompt, buildSystemPromptSections };
366
+ module.exports = {
367
+ buildSystemPrompt,
368
+ buildSystemPromptSections,
369
+ invalidateSystemPromptCache,
370
+ };
@@ -786,10 +786,9 @@ function getAvailableTools(app, options = {}) {
786
786
  type: 'object',
787
787
  properties: {
788
788
  key: { type: 'string', enum: ['user_profile', 'preferences', 'ai_personality'], description: 'user_profile: who the user is, preferences: standing likes/dislikes, ai_personality: concise durable notes for how the agent should behave for this user' },
789
- value: { type: 'string', description: 'Value to set. Keep it concise — this is injected into every single prompt.' },
790
- confirmed: { type: 'boolean', description: 'Must be true only when the user explicitly requested this core-memory change in the current conversation.' }
789
+ value: { type: 'string', description: 'Value to set. Keep it concise — this is injected into every single prompt.' }
791
790
  },
792
- required: ['key', 'value', 'confirmed']
791
+ required: ['key', 'value']
793
792
  }
794
793
  },
795
794
  {
@@ -1895,7 +1894,8 @@ async function executeTool(toolName, args, context, engine) {
1895
1894
  case 'memory_save': {
1896
1895
  const { MemoryManager } = require('../memory/manager');
1897
1896
  const mm = new MemoryManager();
1898
- const id = await mm.saveMemory(userId, args.content, args.category || 'episodic', args.importance || 5, { agentId });
1897
+ const content = typeof args.content === 'string' ? args.content : args.value;
1898
+ const id = await mm.saveMemory(userId, content, args.category || 'episodic', args.importance || 5, { agentId });
1899
1899
  if (!id) {
1900
1900
  return {
1901
1901
  success: true,
@@ -1926,12 +1926,15 @@ async function executeTool(toolName, args, context, engine) {
1926
1926
  }
1927
1927
 
1928
1928
  case 'memory_update_core': {
1929
- const { MemoryManager } = require('../memory/manager');
1929
+ const { MemoryManager, CORE_KEYS } = require('../memory/manager');
1930
1930
  const mm = new MemoryManager();
1931
- if (args.confirmed !== true) {
1932
- return { error: 'Core memory updates require explicit current-session user confirmation.' };
1931
+ if (!CORE_KEYS.includes(args.key)) {
1932
+ return { error: `Core memory key must be one of: ${CORE_KEYS.join(', ')}.` };
1933
+ }
1934
+ if (typeof args.value !== 'string' || !args.value.trim()) {
1935
+ return { error: 'Core memory value must be a non-empty string.' };
1933
1936
  }
1934
- mm.updateCore(userId, args.key, args.value, { agentId, confirmed: true });
1937
+ mm.updateCore(userId, args.key, args.value, { agentId });
1935
1938
  return { success: true, key: args.key, message: 'Core memory updated' };
1936
1939
  }
1937
1940