@hybridaione/hybridclaw 0.2.2 → 0.2.6
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/.github/workflows/ci.yml +70 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +85 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +41 -16
- package/SECURITY.md +17 -0
- package/biome.json +35 -0
- package/config.example.json +71 -8
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/approval-policy.ts +1303 -0
- package/container/src/browser-tools.ts +431 -136
- package/container/src/extensions.ts +36 -12
- package/container/src/hybridai-client.ts +34 -13
- package/container/src/index.ts +451 -109
- package/container/src/ipc.ts +5 -3
- package/container/src/token-usage.ts +20 -10
- package/container/src/tools.ts +599 -225
- package/container/src/types.ts +32 -2
- package/container/src/web-fetch.ts +89 -32
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -2
- package/dist/agent.js.map +1 -1
- package/dist/audit-cli.d.ts.map +1 -1
- package/dist/audit-cli.js +4 -2
- package/dist/audit-cli.js.map +1 -1
- package/dist/audit-events.d.ts.map +1 -1
- package/dist/audit-events.js +53 -3
- package/dist/audit-events.js.map +1 -1
- package/dist/audit-trail.d.ts.map +1 -1
- package/dist/audit-trail.js +17 -8
- package/dist/audit-trail.js.map +1 -1
- package/dist/channels/discord/attachments.d.ts.map +1 -1
- package/dist/channels/discord/attachments.js +14 -7
- package/dist/channels/discord/attachments.js.map +1 -1
- package/dist/channels/discord/debounce.d.ts +9 -0
- package/dist/channels/discord/debounce.d.ts.map +1 -0
- package/dist/channels/discord/debounce.js +20 -0
- package/dist/channels/discord/debounce.js.map +1 -0
- package/dist/channels/discord/delivery.d.ts +4 -1
- package/dist/channels/discord/delivery.d.ts.map +1 -1
- package/dist/channels/discord/delivery.js +19 -3
- package/dist/channels/discord/delivery.js.map +1 -1
- package/dist/channels/discord/human-delay.d.ts +16 -0
- package/dist/channels/discord/human-delay.d.ts.map +1 -0
- package/dist/channels/discord/human-delay.js +29 -0
- package/dist/channels/discord/human-delay.js.map +1 -0
- package/dist/channels/discord/inbound.d.ts +4 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -1
- package/dist/channels/discord/inbound.js +45 -4
- package/dist/channels/discord/inbound.js.map +1 -1
- package/dist/channels/discord/mentions.d.ts.map +1 -1
- package/dist/channels/discord/mentions.js +16 -4
- package/dist/channels/discord/mentions.js.map +1 -1
- package/dist/channels/discord/presence.d.ts +33 -0
- package/dist/channels/discord/presence.d.ts.map +1 -0
- package/dist/channels/discord/presence.js +111 -0
- package/dist/channels/discord/presence.js.map +1 -0
- package/dist/channels/discord/rate-limiter.d.ts +14 -0
- package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
- package/dist/channels/discord/rate-limiter.js +49 -0
- package/dist/channels/discord/rate-limiter.js.map +1 -0
- package/dist/channels/discord/reactions.d.ts +38 -0
- package/dist/channels/discord/reactions.d.ts.map +1 -0
- package/dist/channels/discord/reactions.js +151 -0
- package/dist/channels/discord/reactions.js.map +1 -0
- package/dist/channels/discord/runtime.d.ts +6 -3
- package/dist/channels/discord/runtime.d.ts.map +1 -1
- package/dist/channels/discord/runtime.js +621 -125
- package/dist/channels/discord/runtime.js.map +1 -1
- package/dist/channels/discord/stream.d.ts +4 -1
- package/dist/channels/discord/stream.d.ts.map +1 -1
- package/dist/channels/discord/stream.js +16 -8
- package/dist/channels/discord/stream.js.map +1 -1
- package/dist/channels/discord/tool-actions.d.ts.map +1 -1
- package/dist/channels/discord/tool-actions.js +24 -12
- package/dist/channels/discord/tool-actions.js.map +1 -1
- package/dist/channels/discord/typing.d.ts +15 -0
- package/dist/channels/discord/typing.d.ts.map +1 -0
- package/dist/channels/discord/typing.js +106 -0
- package/dist/channels/discord/typing.js.map +1 -0
- package/dist/chunk.d.ts.map +1 -1
- package/dist/chunk.js +4 -2
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +47 -22
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +103 -18
- package/dist/config.js.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +58 -26
- package/dist/container-runner.js.map +1 -1
- package/dist/container-setup.d.ts.map +1 -1
- package/dist/container-setup.js +10 -9
- package/dist/container-setup.js.map +1 -1
- package/dist/conversation.d.ts +2 -2
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +1 -1
- package/dist/conversation.js.map +1 -1
- package/dist/db.d.ts +118 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +1568 -50
- package/dist/db.js.map +1 -1
- package/dist/delegation-manager.d.ts.map +1 -1
- package/dist/delegation-manager.js +3 -2
- package/dist/delegation-manager.js.map +1 -1
- package/dist/gateway-client.d.ts +2 -2
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +10 -4
- package/dist/gateway-client.js.map +1 -1
- package/dist/gateway-service.d.ts +3 -3
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +563 -73
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-types.d.ts +24 -0
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +179 -24
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +20 -10
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts +4 -0
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +48 -20
- package/dist/heartbeat.js.map +1 -1
- package/dist/hybridai-bots.d.ts.map +1 -1
- package/dist/hybridai-bots.js +4 -2
- package/dist/hybridai-bots.js.map +1 -1
- package/dist/instruction-approval-audit.d.ts.map +1 -1
- package/dist/instruction-approval-audit.js.map +1 -1
- package/dist/instruction-integrity.d.ts.map +1 -1
- package/dist/instruction-integrity.js +8 -2
- package/dist/instruction-integrity.js.map +1 -1
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +6 -1
- package/dist/ipc.js.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/memory-consolidation.d.ts +17 -0
- package/dist/memory-consolidation.d.ts.map +1 -0
- package/dist/memory-consolidation.js +25 -0
- package/dist/memory-consolidation.js.map +1 -0
- package/dist/memory-service.d.ts +200 -0
- package/dist/memory-service.d.ts.map +1 -0
- package/dist/memory-service.js +294 -0
- package/dist/memory-service.js.map +1 -0
- package/dist/mount-security.d.ts.map +1 -1
- package/dist/mount-security.js +31 -7
- package/dist/mount-security.js.map +1 -1
- package/dist/observability-ingest.d.ts.map +1 -1
- package/dist/observability-ingest.js +32 -11
- package/dist/observability-ingest.js.map +1 -1
- package/dist/onboarding.d.ts.map +1 -1
- package/dist/onboarding.js +32 -9
- package/dist/onboarding.js.map +1 -1
- package/dist/proactive-policy.d.ts.map +1 -1
- package/dist/proactive-policy.js +2 -1
- package/dist/proactive-policy.js.map +1 -1
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +9 -7
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +98 -1
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +477 -23
- package/dist/runtime-config.js.map +1 -1
- package/dist/scheduled-task-runner.d.ts +1 -0
- package/dist/scheduled-task-runner.d.ts.map +1 -1
- package/dist/scheduled-task-runner.js +29 -10
- package/dist/scheduled-task-runner.js.map +1 -1
- package/dist/scheduler.d.ts +43 -4
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +530 -56
- package/dist/scheduler.js.map +1 -1
- package/dist/session-export.d.ts +26 -0
- package/dist/session-export.d.ts.map +1 -0
- package/dist/session-export.js +149 -0
- package/dist/session-export.js.map +1 -0
- package/dist/session-maintenance.d.ts.map +1 -1
- package/dist/session-maintenance.js +75 -13
- package/dist/session-maintenance.js.map +1 -1
- package/dist/session-transcripts.d.ts.map +1 -1
- package/dist/session-transcripts.js.map +1 -1
- package/dist/side-effects.d.ts.map +1 -1
- package/dist/side-effects.js +14 -2
- package/dist/side-effects.js.map +1 -1
- package/dist/skills-guard.d.ts.map +1 -1
- package/dist/skills-guard.js +893 -130
- package/dist/skills-guard.js.map +1 -1
- package/dist/skills.d.ts +5 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +29 -15
- package/dist/skills.js.map +1 -1
- package/dist/token-efficiency.d.ts.map +1 -1
- package/dist/token-efficiency.js.map +1 -1
- package/dist/tui.js +92 -11
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +24 -1
- package/dist/types.js.map +1 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +42 -14
- package/dist/update.js.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +49 -9
- package/dist/workspace.js.map +1 -1
- package/docs/chat.html +9 -3
- package/docs/index.html +37 -13
- package/package.json +8 -2
- package/src/agent.ts +16 -3
- package/src/audit-cli.ts +44 -16
- package/src/audit-events.ts +69 -5
- package/src/audit-trail.ts +41 -15
- package/src/channels/discord/attachments.ts +81 -27
- package/src/channels/discord/debounce.ts +25 -0
- package/src/channels/discord/delivery.ts +57 -13
- package/src/channels/discord/human-delay.ts +48 -0
- package/src/channels/discord/inbound.ts +66 -7
- package/src/channels/discord/mentions.ts +42 -18
- package/src/channels/discord/presence.ts +148 -0
- package/src/channels/discord/rate-limiter.ts +58 -0
- package/src/channels/discord/reactions.ts +211 -0
- package/src/channels/discord/runtime.ts +1048 -182
- package/src/channels/discord/stream.ts +73 -27
- package/src/channels/discord/tool-actions.ts +78 -37
- package/src/channels/discord/typing.ts +140 -0
- package/src/chunk.ts +12 -4
- package/src/cli.ts +141 -56
- package/src/config.ts +192 -34
- package/src/container-runner.ts +132 -42
- package/src/container-setup.ts +57 -22
- package/src/conversation.ts +9 -7
- package/src/db.ts +2217 -84
- package/src/delegation-manager.ts +6 -2
- package/src/gateway-client.ts +41 -17
- package/src/gateway-service.ts +1019 -201
- package/src/gateway-types.ts +33 -0
- package/src/gateway.ts +321 -48
- package/src/health.ts +66 -26
- package/src/heartbeat.ts +84 -22
- package/src/hybridai-bots.ts +14 -5
- package/src/instruction-approval-audit.ts +4 -1
- package/src/instruction-integrity.ts +30 -9
- package/src/ipc.ts +23 -5
- package/src/logger.ts +4 -1
- package/src/memory-consolidation.ts +41 -0
- package/src/memory-service.ts +606 -0
- package/src/mount-security.ts +58 -13
- package/src/observability-ingest.ts +134 -35
- package/src/onboarding.ts +126 -35
- package/src/proactive-policy.ts +3 -1
- package/src/prompt-hooks.ts +40 -17
- package/src/runtime-config.ts +1114 -99
- package/src/scheduled-task-runner.ts +63 -11
- package/src/scheduler.ts +683 -60
- package/src/session-export.ts +196 -0
- package/src/session-maintenance.ts +125 -22
- package/src/session-transcripts.ts +12 -3
- package/src/side-effects.ts +28 -5
- package/src/skills-guard.ts +1067 -219
- package/src/skills.ts +163 -65
- package/src/token-efficiency.ts +31 -9
- package/src/tui.ts +166 -25
- package/src/types.ts +195 -2
- package/src/update.ts +79 -23
- package/src/workspace.ts +63 -11
- package/tests/approval-policy.test.ts +224 -0
- package/tests/discord.basic.test.ts +82 -2
- package/tests/discord.human-presence.test.ts +85 -0
- package/tests/gateway-service.media-routing.test.ts +8 -2
- package/tests/memory-service.test.ts +1114 -0
- package/tests/token-efficiency.basic.test.ts +8 -2
- package/vitest.e2e.config.ts +3 -1
- package/vitest.integration.config.ts +3 -1
- package/vitest.live.config.ts +3 -1
- package/vitest.unit.config.ts +9 -0
|
@@ -0,0 +1,1114 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
addKnowledgeEntity,
|
|
9
|
+
addKnowledgeRelation,
|
|
10
|
+
appendCanonicalMessages,
|
|
11
|
+
decaySemanticMemories,
|
|
12
|
+
deleteMemoryValue,
|
|
13
|
+
forgetSemanticMemory,
|
|
14
|
+
getCanonicalContext,
|
|
15
|
+
getMemoryValue,
|
|
16
|
+
getOrCreateSession,
|
|
17
|
+
getUsageTotals,
|
|
18
|
+
initDatabase,
|
|
19
|
+
listMemoryValues,
|
|
20
|
+
listUsageByAgent,
|
|
21
|
+
listUsageByModel,
|
|
22
|
+
listUsageDailyBreakdown,
|
|
23
|
+
queryKnowledgeGraph,
|
|
24
|
+
recallSemanticMemories,
|
|
25
|
+
recordUsageEvent,
|
|
26
|
+
setMemoryValue,
|
|
27
|
+
storeSemanticMemory,
|
|
28
|
+
} from '../src/db.js';
|
|
29
|
+
import {
|
|
30
|
+
computeDecayedConfidence,
|
|
31
|
+
type MemoryBackend,
|
|
32
|
+
MemoryService,
|
|
33
|
+
} from '../src/memory-service.js';
|
|
34
|
+
import {
|
|
35
|
+
KnowledgeEntityType,
|
|
36
|
+
KnowledgeRelationType,
|
|
37
|
+
type SemanticMemoryEntry,
|
|
38
|
+
type Session,
|
|
39
|
+
type StoredMessage,
|
|
40
|
+
} from '../src/types.js';
|
|
41
|
+
|
|
42
|
+
function createTempDbPath(): string {
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hybridclaw-memory-'));
|
|
44
|
+
return path.join(dir, 'test.db');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeSession(partial?: Partial<Session>): Session {
|
|
48
|
+
return {
|
|
49
|
+
id: 'session:test',
|
|
50
|
+
guild_id: null,
|
|
51
|
+
channel_id: 'channel:test',
|
|
52
|
+
chatbot_id: null,
|
|
53
|
+
model: null,
|
|
54
|
+
enable_rag: 1,
|
|
55
|
+
message_count: 0,
|
|
56
|
+
session_summary: null,
|
|
57
|
+
summary_updated_at: null,
|
|
58
|
+
compaction_count: 0,
|
|
59
|
+
memory_flush_at: null,
|
|
60
|
+
created_at: new Date().toISOString(),
|
|
61
|
+
last_active: new Date().toISOString(),
|
|
62
|
+
...(partial || {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe.sequential('semantic memory DB', () => {
|
|
67
|
+
test('recalls topic-matched memories using LIKE-style matching', () => {
|
|
68
|
+
const dbPath = createTempDbPath();
|
|
69
|
+
initDatabase({ quiet: true, dbPath });
|
|
70
|
+
getOrCreateSession('s-like', null, 'channel-a');
|
|
71
|
+
|
|
72
|
+
storeSemanticMemory({
|
|
73
|
+
sessionId: 's-like',
|
|
74
|
+
role: 'user',
|
|
75
|
+
content: 'User prefers Rust for backend services.',
|
|
76
|
+
confidence: 0.95,
|
|
77
|
+
});
|
|
78
|
+
storeSemanticMemory({
|
|
79
|
+
sessionId: 's-like',
|
|
80
|
+
role: 'assistant',
|
|
81
|
+
content: 'Discussed weekend gardening tasks.',
|
|
82
|
+
confidence: 0.95,
|
|
83
|
+
});
|
|
84
|
+
storeSemanticMemory({
|
|
85
|
+
sessionId: 's-like',
|
|
86
|
+
role: 'assistant',
|
|
87
|
+
content: 'Rust ownership and borrowing deep dive.',
|
|
88
|
+
confidence: 0.6,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const results = recallSemanticMemories({
|
|
92
|
+
sessionId: 's-like',
|
|
93
|
+
query: 'rust backend',
|
|
94
|
+
limit: 3,
|
|
95
|
+
minConfidence: 0.2,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(results.length).toBe(2);
|
|
99
|
+
expect(results[0].content.toLowerCase()).toContain('rust');
|
|
100
|
+
expect(
|
|
101
|
+
results.some((row) => row.content.toLowerCase().includes('gardening')),
|
|
102
|
+
).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('ranks semantic memories by cosine similarity when query embedding is provided', () => {
|
|
106
|
+
const dbPath = createTempDbPath();
|
|
107
|
+
initDatabase({ quiet: true, dbPath });
|
|
108
|
+
getOrCreateSession('s-vector', null, 'channel-c');
|
|
109
|
+
|
|
110
|
+
storeSemanticMemory({
|
|
111
|
+
sessionId: 's-vector',
|
|
112
|
+
role: 'assistant',
|
|
113
|
+
content: 'Rust systems programming notes',
|
|
114
|
+
confidence: 0.9,
|
|
115
|
+
embedding: [0.9, 0.1, 0, 0],
|
|
116
|
+
});
|
|
117
|
+
storeSemanticMemory({
|
|
118
|
+
sessionId: 's-vector',
|
|
119
|
+
role: 'assistant',
|
|
120
|
+
content: 'Python scripting notes',
|
|
121
|
+
confidence: 0.9,
|
|
122
|
+
embedding: [0, 0, 0.9, 0.1],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const results = recallSemanticMemories({
|
|
126
|
+
sessionId: 's-vector',
|
|
127
|
+
query: '',
|
|
128
|
+
queryEmbedding: [0.85, 0.15, 0, 0],
|
|
129
|
+
limit: 2,
|
|
130
|
+
minConfidence: 0.2,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(results.length).toBe(2);
|
|
134
|
+
expect(results[0].content.toLowerCase()).toContain('rust');
|
|
135
|
+
expect(results[1].content.toLowerCase()).toContain('python');
|
|
136
|
+
expect(Array.isArray(results[0].embedding)).toBe(true);
|
|
137
|
+
expect(results[0].embedding?.length).toBe(4);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('uses vector recall path when query embedding is provided (no LIKE fallback)', () => {
|
|
141
|
+
const dbPath = createTempDbPath();
|
|
142
|
+
initDatabase({ quiet: true, dbPath });
|
|
143
|
+
getOrCreateSession('s-fallback', null, 'channel-d');
|
|
144
|
+
|
|
145
|
+
storeSemanticMemory({
|
|
146
|
+
sessionId: 's-fallback',
|
|
147
|
+
role: 'assistant',
|
|
148
|
+
content: 'Gardening checklist for spring planting.',
|
|
149
|
+
confidence: 0.9,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const results = recallSemanticMemories({
|
|
153
|
+
sessionId: 's-fallback',
|
|
154
|
+
query: 'changelog concise',
|
|
155
|
+
queryEmbedding: [0.4, 0.2, 0.1, 0.7],
|
|
156
|
+
limit: 3,
|
|
157
|
+
minConfidence: 0.2,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(results.length).toBe(1);
|
|
161
|
+
expect(results[0].content.toLowerCase()).toContain('gardening');
|
|
162
|
+
expect(results[0].embedding).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('decays stale memories and keeps fresh ones unchanged', () => {
|
|
166
|
+
const dbPath = createTempDbPath();
|
|
167
|
+
initDatabase({ quiet: true, dbPath });
|
|
168
|
+
getOrCreateSession('s-decay', null, 'channel-b');
|
|
169
|
+
|
|
170
|
+
const oldDate = new Date(Date.now() - 30 * 86_400_000).toISOString();
|
|
171
|
+
storeSemanticMemory({
|
|
172
|
+
sessionId: 's-decay',
|
|
173
|
+
role: 'assistant',
|
|
174
|
+
content: 'Project alpha decision log.',
|
|
175
|
+
confidence: 0.8,
|
|
176
|
+
createdAt: oldDate,
|
|
177
|
+
accessedAt: oldDate,
|
|
178
|
+
});
|
|
179
|
+
storeSemanticMemory({
|
|
180
|
+
sessionId: 's-decay',
|
|
181
|
+
role: 'assistant',
|
|
182
|
+
content: 'Project beta status update.',
|
|
183
|
+
confidence: 0.8,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const changed = decaySemanticMemories({
|
|
187
|
+
decayRate: 0.5,
|
|
188
|
+
staleAfterDays: 7,
|
|
189
|
+
minConfidence: 0.1,
|
|
190
|
+
});
|
|
191
|
+
expect(changed).toBe(1);
|
|
192
|
+
|
|
193
|
+
const results = recallSemanticMemories({
|
|
194
|
+
sessionId: 's-decay',
|
|
195
|
+
query: 'project',
|
|
196
|
+
limit: 5,
|
|
197
|
+
minConfidence: 0.1,
|
|
198
|
+
});
|
|
199
|
+
const alpha = results.find((row) =>
|
|
200
|
+
row.content.toLowerCase().includes('alpha'),
|
|
201
|
+
);
|
|
202
|
+
const beta = results.find((row) =>
|
|
203
|
+
row.content.toLowerCase().includes('beta'),
|
|
204
|
+
);
|
|
205
|
+
expect(alpha?.confidence).toBeCloseTo(0.4, 5);
|
|
206
|
+
expect(beta?.confidence).toBeCloseTo(0.8, 5);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('default decay matches OpenFang-style confidence*=0.9 with 0.1 floor', () => {
|
|
210
|
+
const dbPath = createTempDbPath();
|
|
211
|
+
initDatabase({ quiet: true, dbPath });
|
|
212
|
+
getOrCreateSession('s-default-decay', null, 'channel-e');
|
|
213
|
+
|
|
214
|
+
const oldDate = new Date(Date.now() - 20 * 86_400_000).toISOString();
|
|
215
|
+
storeSemanticMemory({
|
|
216
|
+
sessionId: 's-default-decay',
|
|
217
|
+
role: 'assistant',
|
|
218
|
+
content: 'Old default-decay memory.',
|
|
219
|
+
confidence: 0.9,
|
|
220
|
+
createdAt: oldDate,
|
|
221
|
+
accessedAt: oldDate,
|
|
222
|
+
});
|
|
223
|
+
storeSemanticMemory({
|
|
224
|
+
sessionId: 's-default-decay',
|
|
225
|
+
role: 'assistant',
|
|
226
|
+
content: 'Already near floor.',
|
|
227
|
+
confidence: 0.1,
|
|
228
|
+
createdAt: oldDate,
|
|
229
|
+
accessedAt: oldDate,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const changed = decaySemanticMemories();
|
|
233
|
+
expect(changed).toBe(1);
|
|
234
|
+
|
|
235
|
+
const results = recallSemanticMemories({
|
|
236
|
+
sessionId: 's-default-decay',
|
|
237
|
+
query: 'default-decay floor',
|
|
238
|
+
limit: 5,
|
|
239
|
+
minConfidence: 0.1,
|
|
240
|
+
});
|
|
241
|
+
const old = results.find((row) =>
|
|
242
|
+
row.content.toLowerCase().includes('default-decay'),
|
|
243
|
+
);
|
|
244
|
+
const floor = results.find((row) =>
|
|
245
|
+
row.content.toLowerCase().includes('near floor'),
|
|
246
|
+
);
|
|
247
|
+
expect(old?.confidence).toBeCloseTo(0.81, 5);
|
|
248
|
+
expect(floor?.confidence).toBeCloseTo(0.1, 5);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe.sequential('structured memory DB', () => {
|
|
253
|
+
test('stores, reads, lists, and deletes key-value memory', () => {
|
|
254
|
+
const dbPath = createTempDbPath();
|
|
255
|
+
initDatabase({ quiet: true, dbPath });
|
|
256
|
+
getOrCreateSession('s-kv', null, 'channel-kv');
|
|
257
|
+
|
|
258
|
+
expect(getMemoryValue('s-kv', 'release.codename')).toBeNull();
|
|
259
|
+
|
|
260
|
+
setMemoryValue('s-kv', 'release.codename', 'AtlasFox');
|
|
261
|
+
setMemoryValue('s-kv', 'release.tag', { format: 'rYY.MM.patch' });
|
|
262
|
+
setMemoryValue('s-kv', 'release.channel', ['stable', 'beta']);
|
|
263
|
+
|
|
264
|
+
expect(getMemoryValue('s-kv', 'release.codename')).toBe('AtlasFox');
|
|
265
|
+
expect(getMemoryValue('s-kv', 'release.tag')).toEqual({
|
|
266
|
+
format: 'rYY.MM.patch',
|
|
267
|
+
});
|
|
268
|
+
expect(getMemoryValue('s-kv', 'release.channel')).toEqual([
|
|
269
|
+
'stable',
|
|
270
|
+
'beta',
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
const prefixed = listMemoryValues('s-kv', 'release.');
|
|
274
|
+
expect(prefixed.length).toBe(3);
|
|
275
|
+
expect(prefixed.every((entry) => entry.key.startsWith('release.'))).toBe(
|
|
276
|
+
true,
|
|
277
|
+
);
|
|
278
|
+
expect(prefixed.every((entry) => entry.agent_id === 's-kv')).toBe(true);
|
|
279
|
+
expect(prefixed.every((entry) => entry.version === 1)).toBe(true);
|
|
280
|
+
|
|
281
|
+
setMemoryValue('s-kv', 'release.tag', {
|
|
282
|
+
format: 'rYY.MM.patch',
|
|
283
|
+
major: 26,
|
|
284
|
+
});
|
|
285
|
+
const updated = listMemoryValues('s-kv', 'release.').find(
|
|
286
|
+
(entry) => entry.key === 'release.tag',
|
|
287
|
+
);
|
|
288
|
+
expect(updated?.version).toBe(2);
|
|
289
|
+
expect(updated?.value).toEqual({ format: 'rYY.MM.patch', major: 26 });
|
|
290
|
+
|
|
291
|
+
expect(deleteMemoryValue('s-kv', 'release.codename')).toBe(true);
|
|
292
|
+
expect(deleteMemoryValue('s-kv', 'release.codename')).toBe(false);
|
|
293
|
+
expect(getMemoryValue('s-kv', 'release.codename')).toBeNull();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe.sequential('schema migrations', () => {
|
|
298
|
+
test('initializes WAL mode and stamps user_version schema', () => {
|
|
299
|
+
const dbPath = createTempDbPath();
|
|
300
|
+
initDatabase({ quiet: true, dbPath });
|
|
301
|
+
|
|
302
|
+
const inspect = new Database(dbPath, { readonly: true });
|
|
303
|
+
const journalMode = inspect.pragma('journal_mode', { simple: true });
|
|
304
|
+
const schemaVersion = inspect.pragma('user_version', { simple: true });
|
|
305
|
+
inspect.close();
|
|
306
|
+
|
|
307
|
+
expect(String(journalMode).toLowerCase()).toBe('wal');
|
|
308
|
+
expect(Number(schemaVersion)).toBe(4);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('migrates legacy memory_kv rows and creates knowledge graph tables', () => {
|
|
312
|
+
const dbPath = createTempDbPath();
|
|
313
|
+
const legacy = new Database(dbPath);
|
|
314
|
+
legacy.exec(`
|
|
315
|
+
CREATE TABLE sessions (
|
|
316
|
+
id TEXT PRIMARY KEY,
|
|
317
|
+
guild_id TEXT,
|
|
318
|
+
channel_id TEXT NOT NULL,
|
|
319
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
320
|
+
last_active TEXT DEFAULT (datetime('now'))
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
CREATE TABLE tasks (
|
|
324
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
325
|
+
session_id TEXT NOT NULL,
|
|
326
|
+
channel_id TEXT NOT NULL,
|
|
327
|
+
cron_expr TEXT NOT NULL,
|
|
328
|
+
prompt TEXT NOT NULL,
|
|
329
|
+
enabled INTEGER DEFAULT 1,
|
|
330
|
+
last_run TEXT,
|
|
331
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
CREATE TABLE semantic_memories (
|
|
335
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
336
|
+
session_id TEXT NOT NULL,
|
|
337
|
+
role TEXT NOT NULL,
|
|
338
|
+
content TEXT NOT NULL,
|
|
339
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
340
|
+
source_message_id INTEGER,
|
|
341
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
342
|
+
accessed_at TEXT DEFAULT (datetime('now')),
|
|
343
|
+
access_count INTEGER NOT NULL DEFAULT 0
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
CREATE TABLE memory_kv (
|
|
347
|
+
session_id TEXT NOT NULL,
|
|
348
|
+
mem_key TEXT NOT NULL,
|
|
349
|
+
value_json TEXT NOT NULL,
|
|
350
|
+
updated_at TEXT,
|
|
351
|
+
PRIMARY KEY (session_id, mem_key)
|
|
352
|
+
);
|
|
353
|
+
`);
|
|
354
|
+
legacy
|
|
355
|
+
.prepare(
|
|
356
|
+
`INSERT INTO memory_kv (session_id, mem_key, value_json, updated_at)
|
|
357
|
+
VALUES (?, ?, ?, datetime('now'))`,
|
|
358
|
+
)
|
|
359
|
+
.run('legacy-session', 'release.codename', '"AtlasFox"');
|
|
360
|
+
legacy.close();
|
|
361
|
+
|
|
362
|
+
initDatabase({ quiet: true, dbPath });
|
|
363
|
+
|
|
364
|
+
expect(getMemoryValue('legacy-session', 'release.codename')).toBe(
|
|
365
|
+
'AtlasFox',
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const inspect = new Database(dbPath, { readonly: true });
|
|
369
|
+
const schemaVersion = inspect.pragma('user_version', { simple: true });
|
|
370
|
+
const hasEntities = inspect
|
|
371
|
+
.prepare(
|
|
372
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entities'",
|
|
373
|
+
)
|
|
374
|
+
.get() as { name: string } | undefined;
|
|
375
|
+
const hasRelations = inspect
|
|
376
|
+
.prepare(
|
|
377
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'relations'",
|
|
378
|
+
)
|
|
379
|
+
.get() as { name: string } | undefined;
|
|
380
|
+
const hasCanonical = inspect
|
|
381
|
+
.prepare(
|
|
382
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'canonical_sessions'",
|
|
383
|
+
)
|
|
384
|
+
.get() as { name: string } | undefined;
|
|
385
|
+
const hasUsage = inspect
|
|
386
|
+
.prepare(
|
|
387
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'usage_events'",
|
|
388
|
+
)
|
|
389
|
+
.get() as { name: string } | undefined;
|
|
390
|
+
inspect.close();
|
|
391
|
+
|
|
392
|
+
expect(Number(schemaVersion)).toBe(4);
|
|
393
|
+
expect(hasEntities?.name).toBe('entities');
|
|
394
|
+
expect(hasRelations?.name).toBe('relations');
|
|
395
|
+
expect(hasCanonical?.name).toBe('canonical_sessions');
|
|
396
|
+
expect(hasUsage?.name).toBe('usage_events');
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe.sequential('knowledge graph DB', () => {
|
|
401
|
+
test('adds entities + relations and queries graph patterns', () => {
|
|
402
|
+
const dbPath = createTempDbPath();
|
|
403
|
+
initDatabase({ quiet: true, dbPath });
|
|
404
|
+
|
|
405
|
+
const aliceId = addKnowledgeEntity({
|
|
406
|
+
id: 'alice',
|
|
407
|
+
name: 'Alice',
|
|
408
|
+
entityType: KnowledgeEntityType.Person,
|
|
409
|
+
});
|
|
410
|
+
const acmeId = addKnowledgeEntity({
|
|
411
|
+
id: 'acme',
|
|
412
|
+
name: 'Acme Corp',
|
|
413
|
+
entityType: KnowledgeEntityType.Organization,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const relationId = addKnowledgeRelation({
|
|
417
|
+
source: aliceId,
|
|
418
|
+
relation: KnowledgeRelationType.WorksAt,
|
|
419
|
+
target: acmeId,
|
|
420
|
+
confidence: 0.95,
|
|
421
|
+
});
|
|
422
|
+
expect(relationId.length).toBeGreaterThan(0);
|
|
423
|
+
|
|
424
|
+
const matches = queryKnowledgeGraph({
|
|
425
|
+
source: 'alice',
|
|
426
|
+
relation: KnowledgeRelationType.WorksAt,
|
|
427
|
+
max_depth: 3,
|
|
428
|
+
});
|
|
429
|
+
expect(matches.length).toBe(1);
|
|
430
|
+
expect(matches[0]?.source.name).toBe('Alice');
|
|
431
|
+
expect(matches[0]?.source.entity_type).toBe(KnowledgeEntityType.Person);
|
|
432
|
+
expect(matches[0]?.target.name).toBe('Acme Corp');
|
|
433
|
+
expect(matches[0]?.relation.relation).toBe(KnowledgeRelationType.WorksAt);
|
|
434
|
+
expect(matches[0]?.relation.confidence).toBeCloseTo(0.95, 5);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('round-trips custom entity/relation types in OpenFang JSON enum shape', () => {
|
|
438
|
+
const dbPath = createTempDbPath();
|
|
439
|
+
initDatabase({ quiet: true, dbPath });
|
|
440
|
+
|
|
441
|
+
const systemId = addKnowledgeEntity({
|
|
442
|
+
id: 'payments',
|
|
443
|
+
name: 'Payments Gateway',
|
|
444
|
+
entityType: 'service',
|
|
445
|
+
properties: { owner: 'platform' },
|
|
446
|
+
});
|
|
447
|
+
const providerId = addKnowledgeEntity({
|
|
448
|
+
id: 'stripe',
|
|
449
|
+
name: 'Stripe',
|
|
450
|
+
entityType: 'vendor',
|
|
451
|
+
});
|
|
452
|
+
addKnowledgeRelation({
|
|
453
|
+
source: systemId,
|
|
454
|
+
relation: 'integrates_with',
|
|
455
|
+
target: providerId,
|
|
456
|
+
properties: { direction: 'outbound' },
|
|
457
|
+
confidence: 0.7,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const matches = queryKnowledgeGraph({
|
|
461
|
+
source: 'payments',
|
|
462
|
+
relation: { custom: 'integrates_with' },
|
|
463
|
+
});
|
|
464
|
+
expect(matches.length).toBe(1);
|
|
465
|
+
expect(matches[0]?.source.entity_type).toEqual({ custom: 'service' });
|
|
466
|
+
expect(matches[0]?.target.entity_type).toEqual({ custom: 'vendor' });
|
|
467
|
+
expect(matches[0]?.relation.relation).toEqual({
|
|
468
|
+
custom: 'integrates_with',
|
|
469
|
+
});
|
|
470
|
+
expect(matches[0]?.relation.properties).toEqual({ direction: 'outbound' });
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe.sequential('canonical sessions DB', () => {
|
|
475
|
+
test('appends cross-channel messages and returns context excluding current session', () => {
|
|
476
|
+
const dbPath = createTempDbPath();
|
|
477
|
+
initDatabase({ quiet: true, dbPath });
|
|
478
|
+
|
|
479
|
+
appendCanonicalMessages({
|
|
480
|
+
agentId: 'agent-main',
|
|
481
|
+
userId: 'user-42',
|
|
482
|
+
newMessages: [
|
|
483
|
+
{
|
|
484
|
+
role: 'user',
|
|
485
|
+
content: 'hello from telegram',
|
|
486
|
+
sessionId: 'tg:1',
|
|
487
|
+
channelId: 'telegram',
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
role: 'assistant',
|
|
491
|
+
content: 'hi, I am here',
|
|
492
|
+
sessionId: 'tg:1',
|
|
493
|
+
channelId: 'telegram',
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
});
|
|
497
|
+
appendCanonicalMessages({
|
|
498
|
+
agentId: 'agent-main',
|
|
499
|
+
userId: 'user-42',
|
|
500
|
+
newMessages: [
|
|
501
|
+
{
|
|
502
|
+
role: 'user',
|
|
503
|
+
content: 'now from discord',
|
|
504
|
+
sessionId: 'dc:9',
|
|
505
|
+
channelId: 'discord',
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const context = getCanonicalContext({
|
|
511
|
+
agentId: 'agent-main',
|
|
512
|
+
userId: 'user-42',
|
|
513
|
+
excludeSessionId: 'dc:9',
|
|
514
|
+
});
|
|
515
|
+
expect(context.summary).toBeNull();
|
|
516
|
+
expect(context.recent_messages.length).toBe(2);
|
|
517
|
+
expect(
|
|
518
|
+
context.recent_messages.every((row) => row.session_id === 'tg:1'),
|
|
519
|
+
).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('compacts canonical history into summary after threshold', () => {
|
|
523
|
+
const dbPath = createTempDbPath();
|
|
524
|
+
initDatabase({ quiet: true, dbPath });
|
|
525
|
+
|
|
526
|
+
const rows = Array.from({ length: 140 }, (_, i) => ({
|
|
527
|
+
role: i % 2 === 0 ? 'user' : 'assistant',
|
|
528
|
+
content: `Message ${i} with enough detail for canonical compaction`,
|
|
529
|
+
sessionId: i < 70 ? 'dc:older' : 'dc:newer',
|
|
530
|
+
channelId: 'discord',
|
|
531
|
+
}));
|
|
532
|
+
appendCanonicalMessages({
|
|
533
|
+
agentId: 'agent-main',
|
|
534
|
+
userId: 'user-77',
|
|
535
|
+
newMessages: rows,
|
|
536
|
+
compactionThreshold: 100,
|
|
537
|
+
windowSize: 50,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const context = getCanonicalContext({
|
|
541
|
+
agentId: 'agent-main',
|
|
542
|
+
userId: 'user-77',
|
|
543
|
+
});
|
|
544
|
+
expect(context.summary).toBeTruthy();
|
|
545
|
+
expect(context.recent_messages.length).toBeLessThanOrEqual(50);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe.sequential('usage aggregation DB', () => {
|
|
550
|
+
test('records usage events and returns daily/monthly + by-model aggregates', () => {
|
|
551
|
+
const dbPath = createTempDbPath();
|
|
552
|
+
initDatabase({ quiet: true, dbPath });
|
|
553
|
+
|
|
554
|
+
recordUsageEvent({
|
|
555
|
+
sessionId: 's-a',
|
|
556
|
+
agentId: 'agent-a',
|
|
557
|
+
model: 'gpt-5-nano',
|
|
558
|
+
inputTokens: 120,
|
|
559
|
+
outputTokens: 30,
|
|
560
|
+
totalTokens: 150,
|
|
561
|
+
toolCalls: 2,
|
|
562
|
+
costUsd: 0.0009,
|
|
563
|
+
});
|
|
564
|
+
recordUsageEvent({
|
|
565
|
+
sessionId: 's-b',
|
|
566
|
+
agentId: 'agent-a',
|
|
567
|
+
model: 'gpt-5-mini',
|
|
568
|
+
inputTokens: 500,
|
|
569
|
+
outputTokens: 150,
|
|
570
|
+
totalTokens: 650,
|
|
571
|
+
toolCalls: 1,
|
|
572
|
+
costUsd: 0.004,
|
|
573
|
+
});
|
|
574
|
+
recordUsageEvent({
|
|
575
|
+
sessionId: 's-c',
|
|
576
|
+
agentId: 'agent-b',
|
|
577
|
+
model: 'gpt-5-nano',
|
|
578
|
+
inputTokens: 200,
|
|
579
|
+
outputTokens: 80,
|
|
580
|
+
totalTokens: 280,
|
|
581
|
+
toolCalls: 0,
|
|
582
|
+
costUsd: 0.0013,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const agentDaily = getUsageTotals({
|
|
586
|
+
agentId: 'agent-a',
|
|
587
|
+
window: 'daily',
|
|
588
|
+
});
|
|
589
|
+
expect(agentDaily.call_count).toBe(2);
|
|
590
|
+
expect(agentDaily.total_tokens).toBe(800);
|
|
591
|
+
expect(agentDaily.total_cost_usd).toBeCloseTo(0.0049, 6);
|
|
592
|
+
|
|
593
|
+
const monthlyByModel = listUsageByModel({
|
|
594
|
+
window: 'monthly',
|
|
595
|
+
});
|
|
596
|
+
expect(monthlyByModel.length).toBe(2);
|
|
597
|
+
expect(monthlyByModel[0]?.model).toBe('gpt-5-mini');
|
|
598
|
+
expect(monthlyByModel[0]?.total_cost_usd).toBeCloseTo(0.004, 6);
|
|
599
|
+
|
|
600
|
+
const dailyByAgent = listUsageByAgent({
|
|
601
|
+
window: 'daily',
|
|
602
|
+
});
|
|
603
|
+
expect(dailyByAgent.length).toBe(2);
|
|
604
|
+
expect(dailyByAgent[0]?.agent_id).toBe('agent-a');
|
|
605
|
+
expect(dailyByAgent[0]?.total_tokens).toBe(800);
|
|
606
|
+
|
|
607
|
+
const dailyBreakdown = listUsageDailyBreakdown({ days: 7 });
|
|
608
|
+
expect(dailyBreakdown.length).toBe(1);
|
|
609
|
+
expect(dailyBreakdown[0]?.call_count).toBe(3);
|
|
610
|
+
expect(dailyBreakdown[0]?.total_tokens).toBe(1_080);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe('MemoryService', () => {
|
|
615
|
+
test('computeDecayedConfidence drops as summary age increases', () => {
|
|
616
|
+
const updatedAt = new Date(Date.now() - 30 * 86_400_000).toISOString();
|
|
617
|
+
const confidence = computeDecayedConfidence({
|
|
618
|
+
updatedAt,
|
|
619
|
+
decayRate: 0.05,
|
|
620
|
+
minConfidence: 0.1,
|
|
621
|
+
nowMs: Date.now(),
|
|
622
|
+
});
|
|
623
|
+
expect(confidence).toBeLessThan(0.25);
|
|
624
|
+
expect(confidence).toBeGreaterThanOrEqual(0.1);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('buildPromptMemoryContext drops stale summary below threshold and does fresh recall calls', () => {
|
|
628
|
+
let recallCalls = 0;
|
|
629
|
+
const recalled: SemanticMemoryEntry[] = [
|
|
630
|
+
{
|
|
631
|
+
id: 1,
|
|
632
|
+
session_id: 'session:test',
|
|
633
|
+
role: 'assistant',
|
|
634
|
+
source: 'conversation',
|
|
635
|
+
scope: 'episodic',
|
|
636
|
+
metadata: {},
|
|
637
|
+
content: 'User likes concise changelog entries.',
|
|
638
|
+
confidence: 0.9,
|
|
639
|
+
embedding: null,
|
|
640
|
+
source_message_id: null,
|
|
641
|
+
created_at: new Date().toISOString(),
|
|
642
|
+
accessed_at: new Date().toISOString(),
|
|
643
|
+
access_count: 0,
|
|
644
|
+
},
|
|
645
|
+
];
|
|
646
|
+
const backend: MemoryBackend = {
|
|
647
|
+
getOrCreateSession: (sessionId, guildId, channelId) =>
|
|
648
|
+
makeSession({
|
|
649
|
+
id: sessionId,
|
|
650
|
+
guild_id: guildId,
|
|
651
|
+
channel_id: channelId,
|
|
652
|
+
}),
|
|
653
|
+
getSessionById: () => makeSession(),
|
|
654
|
+
getConversationHistory: () => [] as StoredMessage[],
|
|
655
|
+
getRecentMessages: () => [] as StoredMessage[],
|
|
656
|
+
get: () => null,
|
|
657
|
+
set: () => {},
|
|
658
|
+
delete: () => false,
|
|
659
|
+
list: () => [],
|
|
660
|
+
appendCanonicalMessages: () => ({
|
|
661
|
+
canonical_id: 'entity-id:u1',
|
|
662
|
+
agent_id: 'entity-id',
|
|
663
|
+
user_id: 'u1',
|
|
664
|
+
messages: [],
|
|
665
|
+
compaction_cursor: 0,
|
|
666
|
+
compacted_summary: null,
|
|
667
|
+
message_count: 0,
|
|
668
|
+
created_at: new Date().toISOString(),
|
|
669
|
+
updated_at: new Date().toISOString(),
|
|
670
|
+
}),
|
|
671
|
+
getCanonicalContext: () => ({ summary: null, recent_messages: [] }),
|
|
672
|
+
addKnowledgeEntity: () => 'entity-id',
|
|
673
|
+
addKnowledgeRelation: () => 'relation-id',
|
|
674
|
+
queryKnowledgeGraph: () => [],
|
|
675
|
+
getCompactionCandidateMessages: () => null,
|
|
676
|
+
storeMessage: () => 42,
|
|
677
|
+
storeSemanticMemory: () => 10,
|
|
678
|
+
recallSemanticMemories: () => {
|
|
679
|
+
recallCalls += 1;
|
|
680
|
+
return recalled.map((row) => ({ ...row }));
|
|
681
|
+
},
|
|
682
|
+
forgetSemanticMemory: () => false,
|
|
683
|
+
decaySemanticMemories: () => 0,
|
|
684
|
+
clearSessionHistory: () => 0,
|
|
685
|
+
deleteMessagesBeforeId: () => 0,
|
|
686
|
+
updateSessionSummary: () => {},
|
|
687
|
+
markSessionMemoryFlush: () => {},
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const service = new MemoryService(backend, {
|
|
691
|
+
summaryDecayRate: 0.2,
|
|
692
|
+
summaryDiscardThreshold: 0.3,
|
|
693
|
+
});
|
|
694
|
+
const staleSession = makeSession({
|
|
695
|
+
session_summary: 'Old summary that should be filtered out.',
|
|
696
|
+
summary_updated_at: new Date(Date.now() - 40 * 86_400_000).toISOString(),
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const first = service.buildPromptMemoryContext({
|
|
700
|
+
session: staleSession,
|
|
701
|
+
query: 'changelog',
|
|
702
|
+
});
|
|
703
|
+
const second = service.buildPromptMemoryContext({
|
|
704
|
+
session: staleSession,
|
|
705
|
+
query: 'changelog',
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
expect(first.summaryConfidence).toBeLessThan(0.3);
|
|
709
|
+
expect(first.promptSummary).toContain('Relevant Memory Recall');
|
|
710
|
+
expect(first.promptSummary).not.toContain(
|
|
711
|
+
'Old summary that should be filtered out.',
|
|
712
|
+
);
|
|
713
|
+
expect(second.promptSummary).toContain('Relevant Memory Recall');
|
|
714
|
+
expect(recallCalls).toBe(2);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('storeTurn writes one interaction semantic memory in OpenFang format', () => {
|
|
718
|
+
const storedSemantic: Array<{
|
|
719
|
+
role: string;
|
|
720
|
+
source?: string | null;
|
|
721
|
+
scope?: string | null;
|
|
722
|
+
content: string;
|
|
723
|
+
sourceMessageId?: number | null;
|
|
724
|
+
}> = [];
|
|
725
|
+
const storedMessages: Array<{ role: string; content: string }> = [];
|
|
726
|
+
let nextMessageId = 100;
|
|
727
|
+
const backend: MemoryBackend = {
|
|
728
|
+
getOrCreateSession: (sessionId, guildId, channelId) =>
|
|
729
|
+
makeSession({
|
|
730
|
+
id: sessionId,
|
|
731
|
+
guild_id: guildId,
|
|
732
|
+
channel_id: channelId,
|
|
733
|
+
}),
|
|
734
|
+
getSessionById: () => makeSession(),
|
|
735
|
+
getConversationHistory: () => [] as StoredMessage[],
|
|
736
|
+
getRecentMessages: () => [] as StoredMessage[],
|
|
737
|
+
get: () => null,
|
|
738
|
+
set: () => {},
|
|
739
|
+
delete: () => false,
|
|
740
|
+
list: () => [],
|
|
741
|
+
appendCanonicalMessages: () => ({
|
|
742
|
+
canonical_id: 'entity-id:u1',
|
|
743
|
+
agent_id: 'entity-id',
|
|
744
|
+
user_id: 'u1',
|
|
745
|
+
messages: [],
|
|
746
|
+
compaction_cursor: 0,
|
|
747
|
+
compacted_summary: null,
|
|
748
|
+
message_count: 0,
|
|
749
|
+
created_at: new Date().toISOString(),
|
|
750
|
+
updated_at: new Date().toISOString(),
|
|
751
|
+
}),
|
|
752
|
+
getCanonicalContext: () => ({ summary: null, recent_messages: [] }),
|
|
753
|
+
addKnowledgeEntity: () => 'entity-id',
|
|
754
|
+
addKnowledgeRelation: () => 'relation-id',
|
|
755
|
+
queryKnowledgeGraph: () => [],
|
|
756
|
+
getCompactionCandidateMessages: () => null,
|
|
757
|
+
storeMessage: (_sessionId, _userId, _username, role, content) => {
|
|
758
|
+
storedMessages.push({ role, content });
|
|
759
|
+
nextMessageId += 1;
|
|
760
|
+
return nextMessageId;
|
|
761
|
+
},
|
|
762
|
+
storeSemanticMemory: ({
|
|
763
|
+
role,
|
|
764
|
+
source,
|
|
765
|
+
scope,
|
|
766
|
+
content,
|
|
767
|
+
sourceMessageId,
|
|
768
|
+
}) => {
|
|
769
|
+
storedSemantic.push({ role, source, scope, content, sourceMessageId });
|
|
770
|
+
return 1;
|
|
771
|
+
},
|
|
772
|
+
recallSemanticMemories: () => [] as SemanticMemoryEntry[],
|
|
773
|
+
forgetSemanticMemory: () => false,
|
|
774
|
+
decaySemanticMemories: () => 0,
|
|
775
|
+
clearSessionHistory: () => 0,
|
|
776
|
+
deleteMessagesBeforeId: () => 0,
|
|
777
|
+
updateSessionSummary: () => {},
|
|
778
|
+
markSessionMemoryFlush: () => {},
|
|
779
|
+
};
|
|
780
|
+
const service = new MemoryService(backend);
|
|
781
|
+
|
|
782
|
+
service.storeTurn({
|
|
783
|
+
sessionId: 'session:test',
|
|
784
|
+
user: {
|
|
785
|
+
userId: 'u1',
|
|
786
|
+
username: 'user',
|
|
787
|
+
content: 'What was the release codename?',
|
|
788
|
+
},
|
|
789
|
+
assistant: {
|
|
790
|
+
userId: 'assistant',
|
|
791
|
+
username: null,
|
|
792
|
+
content: 'The release codename is AtlasFox.',
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
expect(storedMessages.length).toBe(2);
|
|
797
|
+
expect(storedMessages[0]).toEqual({
|
|
798
|
+
role: 'user',
|
|
799
|
+
content: 'What was the release codename?',
|
|
800
|
+
});
|
|
801
|
+
expect(storedMessages[1]).toEqual({
|
|
802
|
+
role: 'assistant',
|
|
803
|
+
content: 'The release codename is AtlasFox.',
|
|
804
|
+
});
|
|
805
|
+
expect(storedSemantic.length).toBe(1);
|
|
806
|
+
expect(storedSemantic[0]?.role).toBe('assistant');
|
|
807
|
+
expect(storedSemantic[0]?.source).toBe('conversation');
|
|
808
|
+
expect(storedSemantic[0]?.scope).toBe('episodic');
|
|
809
|
+
expect(storedSemantic[0]?.content).toBe(
|
|
810
|
+
'User asked: What was the release codename? I responded: The release codename is AtlasFox.',
|
|
811
|
+
);
|
|
812
|
+
expect(storedSemantic[0]?.sourceMessageId).toBe(102);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('storeMessage does not write semantic memory entries', () => {
|
|
816
|
+
let semanticWrites = 0;
|
|
817
|
+
const backend: MemoryBackend = {
|
|
818
|
+
getOrCreateSession: (sessionId, guildId, channelId) =>
|
|
819
|
+
makeSession({
|
|
820
|
+
id: sessionId,
|
|
821
|
+
guild_id: guildId,
|
|
822
|
+
channel_id: channelId,
|
|
823
|
+
}),
|
|
824
|
+
getSessionById: () => makeSession(),
|
|
825
|
+
getConversationHistory: () => [] as StoredMessage[],
|
|
826
|
+
getRecentMessages: () => [] as StoredMessage[],
|
|
827
|
+
get: () => null,
|
|
828
|
+
set: () => {},
|
|
829
|
+
delete: () => false,
|
|
830
|
+
list: () => [],
|
|
831
|
+
appendCanonicalMessages: () => ({
|
|
832
|
+
canonical_id: 'entity-id:u1',
|
|
833
|
+
agent_id: 'entity-id',
|
|
834
|
+
user_id: 'u1',
|
|
835
|
+
messages: [],
|
|
836
|
+
compaction_cursor: 0,
|
|
837
|
+
compacted_summary: null,
|
|
838
|
+
message_count: 0,
|
|
839
|
+
created_at: new Date().toISOString(),
|
|
840
|
+
updated_at: new Date().toISOString(),
|
|
841
|
+
}),
|
|
842
|
+
getCanonicalContext: () => ({ summary: null, recent_messages: [] }),
|
|
843
|
+
addKnowledgeEntity: () => 'entity-id',
|
|
844
|
+
addKnowledgeRelation: () => 'relation-id',
|
|
845
|
+
queryKnowledgeGraph: () => [],
|
|
846
|
+
getCompactionCandidateMessages: () => null,
|
|
847
|
+
storeMessage: () => 42,
|
|
848
|
+
storeSemanticMemory: () => {
|
|
849
|
+
semanticWrites += 1;
|
|
850
|
+
return 10;
|
|
851
|
+
},
|
|
852
|
+
recallSemanticMemories: () => [] as SemanticMemoryEntry[],
|
|
853
|
+
forgetSemanticMemory: () => false,
|
|
854
|
+
decaySemanticMemories: () => 0,
|
|
855
|
+
clearSessionHistory: () => 0,
|
|
856
|
+
deleteMessagesBeforeId: () => 0,
|
|
857
|
+
updateSessionSummary: () => {},
|
|
858
|
+
markSessionMemoryFlush: () => {},
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
const service = new MemoryService(backend);
|
|
862
|
+
service.storeMessage({
|
|
863
|
+
sessionId: 'session:test',
|
|
864
|
+
userId: 'u1',
|
|
865
|
+
username: 'user',
|
|
866
|
+
role: 'user',
|
|
867
|
+
content: 'Remember this codename please.',
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
expect(semanticWrites).toBe(0);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('semantic recall increments access_count on repeated identical queries', () => {
|
|
874
|
+
const dbPath = createTempDbPath();
|
|
875
|
+
initDatabase({ quiet: true, dbPath });
|
|
876
|
+
getOrCreateSession('s-access', null, 'channel-h');
|
|
877
|
+
|
|
878
|
+
storeSemanticMemory({
|
|
879
|
+
sessionId: 's-access',
|
|
880
|
+
role: 'assistant',
|
|
881
|
+
content: 'Release codename is AtlasFox.',
|
|
882
|
+
confidence: 1,
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const service = new MemoryService();
|
|
886
|
+
const first = service.recallSemanticMemories({
|
|
887
|
+
sessionId: 's-access',
|
|
888
|
+
query: 'release codename atlasfox',
|
|
889
|
+
limit: 1,
|
|
890
|
+
minConfidence: 0.1,
|
|
891
|
+
});
|
|
892
|
+
const second = service.recallSemanticMemories({
|
|
893
|
+
sessionId: 's-access',
|
|
894
|
+
query: 'release codename atlasfox',
|
|
895
|
+
limit: 1,
|
|
896
|
+
minConfidence: 0.1,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
expect(first[0]?.access_count).toBe(0);
|
|
900
|
+
expect(second[0]?.access_count).toBe(1);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test('forgets semantic memory and excludes it from recall', () => {
|
|
904
|
+
const dbPath = createTempDbPath();
|
|
905
|
+
initDatabase({ quiet: true, dbPath });
|
|
906
|
+
getOrCreateSession('s-forget', null, 'channel-f');
|
|
907
|
+
|
|
908
|
+
const keepId = storeSemanticMemory({
|
|
909
|
+
sessionId: 's-forget',
|
|
910
|
+
role: 'user',
|
|
911
|
+
content: 'Keep this memory available.',
|
|
912
|
+
confidence: 1,
|
|
913
|
+
});
|
|
914
|
+
const deleteId = storeSemanticMemory({
|
|
915
|
+
sessionId: 's-forget',
|
|
916
|
+
role: 'user',
|
|
917
|
+
content: 'Delete this memory now.',
|
|
918
|
+
confidence: 1,
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
expect(forgetSemanticMemory(deleteId)).toBe(true);
|
|
922
|
+
expect(forgetSemanticMemory(deleteId)).toBe(false);
|
|
923
|
+
|
|
924
|
+
const results = recallSemanticMemories({
|
|
925
|
+
sessionId: 's-forget',
|
|
926
|
+
query: 'memory',
|
|
927
|
+
minConfidence: 0.1,
|
|
928
|
+
limit: 10,
|
|
929
|
+
});
|
|
930
|
+
const ids = new Set(results.map((row) => row.id));
|
|
931
|
+
expect(ids.has(keepId)).toBe(true);
|
|
932
|
+
expect(ids.has(deleteId)).toBe(false);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test('recalls with source/scope filters', () => {
|
|
936
|
+
const dbPath = createTempDbPath();
|
|
937
|
+
initDatabase({ quiet: true, dbPath });
|
|
938
|
+
getOrCreateSession('s-filter', null, 'channel-g');
|
|
939
|
+
|
|
940
|
+
storeSemanticMemory({
|
|
941
|
+
sessionId: 's-filter',
|
|
942
|
+
role: 'user',
|
|
943
|
+
source: 'conversation',
|
|
944
|
+
scope: 'episodic',
|
|
945
|
+
content: 'AtlasFox codename context.',
|
|
946
|
+
confidence: 0.9,
|
|
947
|
+
});
|
|
948
|
+
storeSemanticMemory({
|
|
949
|
+
sessionId: 's-filter',
|
|
950
|
+
role: 'user',
|
|
951
|
+
source: 'tool',
|
|
952
|
+
scope: 'project',
|
|
953
|
+
content: 'AtlasFox release metadata.',
|
|
954
|
+
confidence: 0.9,
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
const episodic = recallSemanticMemories({
|
|
958
|
+
sessionId: 's-filter',
|
|
959
|
+
query: 'AtlasFox',
|
|
960
|
+
minConfidence: 0.1,
|
|
961
|
+
filter: { source: 'conversation', scope: 'episodic' },
|
|
962
|
+
limit: 10,
|
|
963
|
+
});
|
|
964
|
+
const project = recallSemanticMemories({
|
|
965
|
+
sessionId: 's-filter',
|
|
966
|
+
query: 'AtlasFox',
|
|
967
|
+
minConfidence: 0.1,
|
|
968
|
+
filter: { source: 'tool', scope: 'project' },
|
|
969
|
+
limit: 10,
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
expect(episodic.length).toBe(1);
|
|
973
|
+
expect(episodic[0]?.source).toBe('conversation');
|
|
974
|
+
expect(episodic[0]?.scope).toBe('episodic');
|
|
975
|
+
expect(project.length).toBe(1);
|
|
976
|
+
expect(project[0]?.source).toBe('tool');
|
|
977
|
+
expect(project[0]?.scope).toBe('project');
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test('proxies structured, canonical, and knowledge graph methods through MemoryService', () => {
|
|
981
|
+
const graphResults = [
|
|
982
|
+
{
|
|
983
|
+
source: {
|
|
984
|
+
id: 'alice',
|
|
985
|
+
entity_type: KnowledgeEntityType.Person,
|
|
986
|
+
name: 'Alice',
|
|
987
|
+
properties: {},
|
|
988
|
+
created_at: new Date().toISOString(),
|
|
989
|
+
updated_at: new Date().toISOString(),
|
|
990
|
+
},
|
|
991
|
+
relation: {
|
|
992
|
+
source: 'alice',
|
|
993
|
+
relation: KnowledgeRelationType.WorksAt,
|
|
994
|
+
target: 'acme',
|
|
995
|
+
properties: {},
|
|
996
|
+
confidence: 0.95,
|
|
997
|
+
created_at: new Date().toISOString(),
|
|
998
|
+
},
|
|
999
|
+
target: {
|
|
1000
|
+
id: 'acme',
|
|
1001
|
+
entity_type: KnowledgeEntityType.Organization,
|
|
1002
|
+
name: 'Acme Corp',
|
|
1003
|
+
properties: {},
|
|
1004
|
+
created_at: new Date().toISOString(),
|
|
1005
|
+
updated_at: new Date().toISOString(),
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
];
|
|
1009
|
+
const canonicalContext = {
|
|
1010
|
+
summary: 'User discussed release schedule on another channel.',
|
|
1011
|
+
recent_messages: [
|
|
1012
|
+
{
|
|
1013
|
+
role: 'assistant',
|
|
1014
|
+
content: 'Release is planned for Friday.',
|
|
1015
|
+
session_id: 'other:session',
|
|
1016
|
+
channel_id: 'discord',
|
|
1017
|
+
created_at: new Date().toISOString(),
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
1020
|
+
};
|
|
1021
|
+
const backend: MemoryBackend = {
|
|
1022
|
+
getOrCreateSession: (sessionId, guildId, channelId) =>
|
|
1023
|
+
makeSession({
|
|
1024
|
+
id: sessionId,
|
|
1025
|
+
guild_id: guildId,
|
|
1026
|
+
channel_id: channelId,
|
|
1027
|
+
}),
|
|
1028
|
+
getSessionById: () => makeSession(),
|
|
1029
|
+
getConversationHistory: () => [] as StoredMessage[],
|
|
1030
|
+
getRecentMessages: () => [] as StoredMessage[],
|
|
1031
|
+
get: (_sessionId, key) =>
|
|
1032
|
+
key === 'release.codename' ? 'AtlasFox' : null,
|
|
1033
|
+
set: () => {},
|
|
1034
|
+
delete: (_sessionId, key) => key === 'release.codename',
|
|
1035
|
+
list: () => [
|
|
1036
|
+
{
|
|
1037
|
+
agent_id: 'session:test',
|
|
1038
|
+
key: 'release.codename',
|
|
1039
|
+
value: 'AtlasFox',
|
|
1040
|
+
version: 3,
|
|
1041
|
+
updated_at: new Date().toISOString(),
|
|
1042
|
+
},
|
|
1043
|
+
],
|
|
1044
|
+
appendCanonicalMessages: () => ({
|
|
1045
|
+
canonical_id: 'entity-id:u1',
|
|
1046
|
+
agent_id: 'entity-id',
|
|
1047
|
+
user_id: 'u1',
|
|
1048
|
+
messages: [],
|
|
1049
|
+
compaction_cursor: 0,
|
|
1050
|
+
compacted_summary: null,
|
|
1051
|
+
message_count: 0,
|
|
1052
|
+
created_at: new Date().toISOString(),
|
|
1053
|
+
updated_at: new Date().toISOString(),
|
|
1054
|
+
}),
|
|
1055
|
+
getCanonicalContext: () => canonicalContext,
|
|
1056
|
+
addKnowledgeEntity: () => 'entity-id',
|
|
1057
|
+
addKnowledgeRelation: () => 'relation-id',
|
|
1058
|
+
queryKnowledgeGraph: () => graphResults,
|
|
1059
|
+
getCompactionCandidateMessages: () => null,
|
|
1060
|
+
storeMessage: () => 42,
|
|
1061
|
+
storeSemanticMemory: () => 10,
|
|
1062
|
+
recallSemanticMemories: () => [] as SemanticMemoryEntry[],
|
|
1063
|
+
forgetSemanticMemory: () => false,
|
|
1064
|
+
decaySemanticMemories: () => 0,
|
|
1065
|
+
clearSessionHistory: () => 0,
|
|
1066
|
+
deleteMessagesBeforeId: () => 0,
|
|
1067
|
+
updateSessionSummary: () => {},
|
|
1068
|
+
markSessionMemoryFlush: () => {},
|
|
1069
|
+
};
|
|
1070
|
+
const service = new MemoryService(backend);
|
|
1071
|
+
|
|
1072
|
+
expect(service.get('session:test', 'release.codename')).toBe('AtlasFox');
|
|
1073
|
+
service.set('session:test', 'release.codename', 'AtlasFox');
|
|
1074
|
+
expect(service.delete('session:test', 'release.codename')).toBe(true);
|
|
1075
|
+
expect(service.list('session:test', 'release.').length).toBe(1);
|
|
1076
|
+
expect(
|
|
1077
|
+
service.appendCanonicalMessages({
|
|
1078
|
+
agentId: 'entity-id',
|
|
1079
|
+
userId: 'u1',
|
|
1080
|
+
newMessages: [
|
|
1081
|
+
{
|
|
1082
|
+
role: 'user',
|
|
1083
|
+
content: 'Remember this across channels.',
|
|
1084
|
+
sessionId: 'session:test',
|
|
1085
|
+
},
|
|
1086
|
+
],
|
|
1087
|
+
}).canonical_id,
|
|
1088
|
+
).toBe('entity-id:u1');
|
|
1089
|
+
expect(
|
|
1090
|
+
service.getCanonicalContext({
|
|
1091
|
+
agentId: 'entity-id',
|
|
1092
|
+
userId: 'u1',
|
|
1093
|
+
}),
|
|
1094
|
+
).toEqual(canonicalContext);
|
|
1095
|
+
expect(
|
|
1096
|
+
service.addKnowledgeEntity({
|
|
1097
|
+
name: 'Alice',
|
|
1098
|
+
entityType: KnowledgeEntityType.Person,
|
|
1099
|
+
}),
|
|
1100
|
+
).toBe('entity-id');
|
|
1101
|
+
expect(
|
|
1102
|
+
service.addKnowledgeRelation({
|
|
1103
|
+
source: 'alice',
|
|
1104
|
+
relation: KnowledgeRelationType.WorksAt,
|
|
1105
|
+
target: 'acme',
|
|
1106
|
+
}),
|
|
1107
|
+
).toBe('relation-id');
|
|
1108
|
+
expect(
|
|
1109
|
+
service.queryKnowledgeGraph({
|
|
1110
|
+
source: 'alice',
|
|
1111
|
+
}),
|
|
1112
|
+
).toEqual(graphResults);
|
|
1113
|
+
});
|
|
1114
|
+
});
|