morpheus-cli 0.2.0 → 0.2.3

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 (34) hide show
  1. package/README.md +346 -273
  2. package/dist/cli/commands/doctor.js +36 -1
  3. package/dist/cli/commands/init.js +92 -0
  4. package/dist/cli/commands/start.js +2 -1
  5. package/dist/cli/index.js +1 -17
  6. package/dist/cli/utils/render.js +2 -1
  7. package/dist/cli/utils/version.js +16 -0
  8. package/dist/config/manager.js +16 -0
  9. package/dist/config/schemas.js +15 -8
  10. package/dist/http/api.js +111 -0
  11. package/dist/runtime/__tests__/manual_santi_verify.js +55 -0
  12. package/dist/runtime/display.js +3 -0
  13. package/dist/runtime/memory/sati/__tests__/repository.test.js +71 -0
  14. package/dist/runtime/memory/sati/__tests__/service.test.js +99 -0
  15. package/dist/runtime/memory/sati/index.js +58 -0
  16. package/dist/runtime/memory/sati/repository.js +226 -0
  17. package/dist/runtime/memory/sati/service.js +142 -0
  18. package/dist/runtime/memory/sati/system-prompts.js +42 -0
  19. package/dist/runtime/memory/sati/types.js +1 -0
  20. package/dist/runtime/memory/sqlite.js +5 -1
  21. package/dist/runtime/migration.js +53 -1
  22. package/dist/runtime/oracle.js +32 -7
  23. package/dist/runtime/santi/contracts.js +1 -0
  24. package/dist/runtime/santi/middleware.js +61 -0
  25. package/dist/runtime/santi/santi.js +109 -0
  26. package/dist/runtime/santi/store.js +158 -0
  27. package/dist/runtime/tools/factory.js +31 -25
  28. package/dist/types/config.js +1 -0
  29. package/dist/ui/assets/index-BLLLlr0w.css +1 -0
  30. package/dist/ui/assets/index-Ccml5qIL.js +50 -0
  31. package/dist/ui/index.html +2 -2
  32. package/package.json +2 -2
  33. package/dist/ui/assets/index-AEbYNHuy.css +0 -1
  34. package/dist/ui/assets/index-BjnI8c1U.js +0 -50
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import { ConfigManager } from '../../config/manager.js';
5
5
  import { renderBanner } from '../utils/render.js';
6
6
  import { DisplayManager } from '../../runtime/display.js';
7
+ import { SatiRepository } from '../../runtime/memory/sati/repository.js';
7
8
  // import { scaffold } from '../../runtime/scaffold.js';
8
9
  export const initCommand = new Command('init')
9
10
  .description('Initialize Morpheus configuration')
@@ -74,6 +75,89 @@ export const initCommand = new Command('init')
74
75
  if (apiKey) {
75
76
  await configManager.set('llm.api_key', apiKey);
76
77
  }
78
+ // Context Window Configuration
79
+ const contextWindow = await input({
80
+ message: 'Context Window Size (number of messages to send to LLM):',
81
+ default: currentConfig.llm.context_window?.toString() || '100',
82
+ validate: (val) => (!isNaN(Number(val)) && Number(val) > 0) || 'Must be a positive number'
83
+ });
84
+ await configManager.set('llm.context_window', Number(contextWindow));
85
+ // Sati (Memory Agent) Configuration
86
+ display.log(chalk.blue('\nSati (Memory Agent) Configuration'));
87
+ const configureSati = await select({
88
+ message: 'Configure Sati separately?',
89
+ choices: [
90
+ { name: 'No (Use main LLM settings)', value: 'no' },
91
+ { name: 'Yes', value: 'yes' },
92
+ ],
93
+ default: 'no',
94
+ });
95
+ let santiProvider = provider;
96
+ let santiModel = model;
97
+ let santiApiKey = apiKey;
98
+ // If using main settings and no new key provided, use existing if available
99
+ if (configureSati === 'no' && !santiApiKey && hasExistingKey) {
100
+ santiApiKey = currentConfig.llm.api_key;
101
+ }
102
+ if (configureSati === 'yes') {
103
+ santiProvider = await select({
104
+ message: 'Select Sati LLM Provider:',
105
+ choices: [
106
+ { name: 'OpenAI', value: 'openai' },
107
+ { name: 'Anthropic', value: 'anthropic' },
108
+ { name: 'Ollama', value: 'ollama' },
109
+ { name: 'Google Gemini', value: 'gemini' },
110
+ ],
111
+ default: currentConfig.santi?.provider || provider,
112
+ });
113
+ let defaultSatiModel = 'gpt-3.5-turbo';
114
+ switch (santiProvider) {
115
+ case 'openai':
116
+ defaultSatiModel = 'gpt-4o';
117
+ break;
118
+ case 'anthropic':
119
+ defaultSatiModel = 'claude-3-5-sonnet-20240620';
120
+ break;
121
+ case 'ollama':
122
+ defaultSatiModel = 'llama3';
123
+ break;
124
+ case 'gemini':
125
+ defaultSatiModel = 'gemini-pro';
126
+ break;
127
+ }
128
+ if (santiProvider === currentConfig.santi?.provider) {
129
+ defaultSatiModel = currentConfig.santi?.model || defaultSatiModel;
130
+ }
131
+ santiModel = await input({
132
+ message: 'Enter Sati Model Name:',
133
+ default: defaultSatiModel,
134
+ });
135
+ const hasExistingSatiKey = !!currentConfig.santi?.api_key;
136
+ const santiKeyMsg = hasExistingSatiKey
137
+ ? 'Enter Sati API Key (leave empty to preserve existing):'
138
+ : 'Enter Sati API Key:';
139
+ const keyInput = await password({ message: santiKeyMsg });
140
+ if (keyInput) {
141
+ santiApiKey = keyInput;
142
+ }
143
+ else if (hasExistingSatiKey) {
144
+ santiApiKey = currentConfig.santi?.api_key;
145
+ }
146
+ else {
147
+ santiApiKey = undefined; // Ensure we don't accidentally carry over invalid state
148
+ }
149
+ }
150
+ const memoryLimit = await input({
151
+ message: 'Sati Memory Retrieval Limit (messages):',
152
+ default: currentConfig.santi?.memory_limit?.toString() || '1000',
153
+ validate: (val) => !isNaN(Number(val)) && Number(val) > 0 || 'Must be a positive number'
154
+ });
155
+ await configManager.set('santi.provider', santiProvider);
156
+ await configManager.set('santi.model', santiModel);
157
+ await configManager.set('santi.memory_limit', Number(memoryLimit));
158
+ if (santiApiKey) {
159
+ await configManager.set('santi.api_key', santiApiKey);
160
+ }
77
161
  // Audio Configuration
78
162
  const audioEnabled = await confirm({
79
163
  message: 'Enable Audio Transcription? (Requires Gemini)',
@@ -152,6 +236,14 @@ export const initCommand = new Command('init')
152
236
  await configManager.set('channels.telegram.allowedUsers', allowedUsers);
153
237
  }
154
238
  }
239
+ // Initialize Sati Memory (Long-term memory)
240
+ try {
241
+ SatiRepository.getInstance().initialize();
242
+ display.log(chalk.green('Long-term memory initialized.'));
243
+ }
244
+ catch (e) {
245
+ display.log(chalk.yellow(`Warning: Could not initialize long-term memory: ${e.message}`));
246
+ }
155
247
  display.log(chalk.green('\nConfiguration saved successfully!'));
156
248
  display.log(chalk.cyan(`Run 'morpheus start' to launch ${name}.`));
157
249
  }
@@ -11,6 +11,7 @@ import { PATHS } from '../../config/paths.js';
11
11
  import { Oracle } from '../../runtime/oracle.js';
12
12
  import { ProviderError } from '../../runtime/errors.js';
13
13
  import { HttpServer } from '../../http/server.js';
14
+ import { getVersion } from '../utils/version.js';
14
15
  export const startCommand = new Command('start')
15
16
  .description('Start the Morpheus agent')
16
17
  .option('--ui', 'Enable web UI', true)
@@ -19,7 +20,7 @@ export const startCommand = new Command('start')
19
20
  .action(async (options) => {
20
21
  const display = DisplayManager.getInstance();
21
22
  try {
22
- renderBanner();
23
+ renderBanner(getVersion());
23
24
  await scaffold(); // Ensure env exists
24
25
  // Cleanup stale PID first
25
26
  await checkStalePid();
package/dist/cli/index.js CHANGED
@@ -1,7 +1,4 @@
1
1
  import { Command } from 'commander';
2
- import { readFileSync } from 'fs';
3
- import { fileURLToPath } from 'url';
4
- import { dirname, join } from 'path';
5
2
  import { startCommand } from './commands/start.js';
6
3
  import { stopCommand } from './commands/stop.js';
7
4
  import { statusCommand } from './commands/status.js';
@@ -9,20 +6,7 @@ import { configCommand } from './commands/config.js';
9
6
  import { doctorCommand } from './commands/doctor.js';
10
7
  import { initCommand } from './commands/init.js';
11
8
  import { scaffold } from '../runtime/scaffold.js';
12
- // Helper to read package.json version
13
- const getVersion = () => {
14
- try {
15
- const __filename = fileURLToPath(import.meta.url);
16
- const __dirname = dirname(__filename);
17
- // Assuming dist/cli/index.js -> package.json is 2 levels up
18
- const pkgPath = join(__dirname, '../../package.json');
19
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
20
- return pkg.version;
21
- }
22
- catch (e) {
23
- return '0.1.0';
24
- }
25
- };
9
+ import { getVersion } from './utils/version.js';
26
10
  export async function cli() {
27
11
  const program = new Command();
28
12
  program
@@ -1,6 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import figlet from 'figlet';
3
- export function renderBanner() {
3
+ export function renderBanner(version) {
4
4
  const art = figlet.textSync('Morpheus', {
5
5
  font: 'Standard',
6
6
  horizontalLayout: 'default',
@@ -8,4 +8,5 @@ export function renderBanner() {
8
8
  });
9
9
  console.log(chalk.cyanBright(art));
10
10
  console.log(chalk.gray(' The Local-First AI Agent specialized in Coding\n'));
11
+ console.log(chalk.gray(` v${version || 'unknown'}\n`));
11
12
  }
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ export const getVersion = () => {
5
+ try {
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+ // Assuming dist/cli/index.js -> package.json is 2 levels up
9
+ const pkgPath = join(__dirname, '../../../package.json');
10
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
11
+ return pkg.version;
12
+ }
13
+ catch (e) {
14
+ return '0.1.0';
15
+ }
16
+ };
@@ -54,4 +54,20 @@ export class ConfigManager {
54
54
  await fs.writeFile(PATHS.config, yaml.dump(valid), 'utf8');
55
55
  this.config = valid;
56
56
  }
57
+ getLLMConfig() {
58
+ return this.config.llm;
59
+ }
60
+ getSatiConfig() {
61
+ if (this.config.santi) {
62
+ return {
63
+ memory_limit: 10, // Default if undefined
64
+ ...this.config.santi
65
+ };
66
+ }
67
+ // Fallback to main LLM config
68
+ return {
69
+ ...this.config.llm,
70
+ memory_limit: 10 // Default fallback
71
+ };
72
+ }
57
73
  }
@@ -7,22 +7,28 @@ export const AudioConfigSchema = z.object({
7
7
  maxDurationSeconds: z.number().default(DEFAULT_CONFIG.audio.maxDurationSeconds),
8
8
  supportedMimeTypes: z.array(z.string()).default(DEFAULT_CONFIG.audio.supportedMimeTypes),
9
9
  });
10
+ export const LLMConfigSchema = z.object({
11
+ provider: z.enum(['openai', 'anthropic', 'ollama', 'gemini']).default(DEFAULT_CONFIG.llm.provider),
12
+ model: z.string().min(1).default(DEFAULT_CONFIG.llm.model),
13
+ temperature: z.number().min(0).max(1).default(DEFAULT_CONFIG.llm.temperature),
14
+ max_tokens: z.number().int().positive().optional(),
15
+ api_key: z.string().optional(),
16
+ context_window: z.number().int().positive().optional(),
17
+ });
18
+ export const SatiConfigSchema = LLMConfigSchema.extend({
19
+ memory_limit: z.number().int().positive().optional(),
20
+ });
10
21
  // Zod Schema matching MorpheusConfig interface
11
22
  export const ConfigSchema = z.object({
12
23
  agent: z.object({
13
24
  name: z.string().default(DEFAULT_CONFIG.agent.name),
14
25
  personality: z.string().default(DEFAULT_CONFIG.agent.personality),
15
26
  }).default(DEFAULT_CONFIG.agent),
16
- llm: z.object({
17
- provider: z.enum(['openai', 'anthropic', 'ollama', 'gemini']).default(DEFAULT_CONFIG.llm.provider),
18
- model: z.string().min(1).default(DEFAULT_CONFIG.llm.model),
19
- temperature: z.number().min(0).max(1).default(DEFAULT_CONFIG.llm.temperature),
20
- max_tokens: z.number().int().positive().optional(),
21
- api_key: z.string().optional(),
22
- }).default(DEFAULT_CONFIG.llm),
27
+ llm: LLMConfigSchema.default(DEFAULT_CONFIG.llm),
28
+ santi: SatiConfigSchema.optional(),
23
29
  audio: AudioConfigSchema.default(DEFAULT_CONFIG.audio),
24
30
  memory: z.object({
25
- limit: z.number().int().positive().default(DEFAULT_CONFIG.memory.limit),
31
+ limit: z.number().int().positive().optional(),
26
32
  }).default(DEFAULT_CONFIG.memory),
27
33
  channels: z.object({
28
34
  telegram: z.object({
@@ -56,6 +62,7 @@ export const MCPServerConfigSchema = z.discriminatedUnion('transport', [
56
62
  z.object({
57
63
  transport: z.literal('http'),
58
64
  url: z.string().url('Valid URL is required for http transport'),
65
+ headers: z.record(z.string(), z.string()).optional().default({}),
59
66
  args: z.array(z.string()).optional().default([]),
60
67
  env: z.record(z.string(), z.string()).optional().default({}),
61
68
  _comment: z.string().optional(),
package/dist/http/api.js CHANGED
@@ -5,6 +5,7 @@ import { DisplayManager } from '../runtime/display.js';
5
5
  import fs from 'fs-extra';
6
6
  import path from 'path';
7
7
  import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
8
+ import { SatiRepository } from '../runtime/memory/sati/repository.js';
8
9
  async function readLastLines(filePath, n) {
9
10
  try {
10
11
  const content = await fs.readFile(filePath, 'utf8');
@@ -113,6 +114,116 @@ export function createApiRouter() {
113
114
  }
114
115
  }
115
116
  });
117
+ // Sati config endpoints
118
+ router.get('/config/sati', (req, res) => {
119
+ try {
120
+ const satiConfig = configManager.getSatiConfig();
121
+ res.json(satiConfig);
122
+ }
123
+ catch (error) {
124
+ res.status(500).json({ error: error.message });
125
+ }
126
+ });
127
+ router.post('/config/sati', async (req, res) => {
128
+ try {
129
+ const config = configManager.get();
130
+ await configManager.save({ ...config, santi: req.body });
131
+ const display = DisplayManager.getInstance();
132
+ display.log('Sati configuration updated via UI', {
133
+ source: 'Zaion',
134
+ level: 'info'
135
+ });
136
+ res.json({ success: true });
137
+ }
138
+ catch (error) {
139
+ if (error.name === 'ZodError') {
140
+ res.status(400).json({ error: 'Validation failed', details: error.errors });
141
+ }
142
+ else {
143
+ res.status(500).json({ error: error.message });
144
+ }
145
+ }
146
+ });
147
+ router.delete('/config/sati', async (req, res) => {
148
+ try {
149
+ const config = configManager.get();
150
+ const { santi, ...restConfig } = config;
151
+ await configManager.save(restConfig);
152
+ const display = DisplayManager.getInstance();
153
+ display.log('Sati configuration removed via UI (falling back to Oracle config)', {
154
+ source: 'Zaion',
155
+ level: 'info'
156
+ });
157
+ res.json({ success: true });
158
+ }
159
+ catch (error) {
160
+ res.status(500).json({ error: error.message });
161
+ }
162
+ });
163
+ // Sati memories endpoints
164
+ router.get('/sati/memories', async (req, res) => {
165
+ try {
166
+ const repository = SatiRepository.getInstance();
167
+ const memories = repository.getAllMemories();
168
+ // Convert dates to ISO strings for JSON serialization
169
+ const serializedMemories = memories.map(memory => ({
170
+ ...memory,
171
+ created_at: memory.created_at.toISOString(),
172
+ updated_at: memory.updated_at.toISOString(),
173
+ last_accessed_at: memory.last_accessed_at ? memory.last_accessed_at.toISOString() : null
174
+ }));
175
+ res.json(serializedMemories);
176
+ }
177
+ catch (error) {
178
+ res.status(500).json({ error: error.message });
179
+ }
180
+ });
181
+ router.delete('/sati/memories/:id', async (req, res) => {
182
+ try {
183
+ const { id } = req.params;
184
+ const repository = SatiRepository.getInstance();
185
+ const success = repository.archiveMemory(id);
186
+ if (!success) {
187
+ return res.status(404).json({ error: 'Memory not found' });
188
+ }
189
+ res.json({ success: true, message: 'Memory archived successfully' });
190
+ }
191
+ catch (error) {
192
+ res.status(500).json({ error: error.message });
193
+ }
194
+ });
195
+ router.post('/sati/memories/bulk-delete', async (req, res) => {
196
+ try {
197
+ const { ids } = req.body;
198
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
199
+ return res.status(400).json({ error: 'Ids array is required and cannot be empty' });
200
+ }
201
+ const repository = SatiRepository.getInstance();
202
+ let deletedCount = 0;
203
+ // Use a transaction for atomicity, but check if db is not null
204
+ const db = repository['db'];
205
+ if (!db) {
206
+ return res.status(500).json({ error: 'Database connection is not available' });
207
+ }
208
+ const transaction = db.transaction((memoryIds) => {
209
+ for (const id of memoryIds) {
210
+ const success = repository.archiveMemory(id);
211
+ if (success) {
212
+ deletedCount++;
213
+ }
214
+ }
215
+ });
216
+ transaction(ids);
217
+ res.json({
218
+ success: true,
219
+ message: `${deletedCount} memories archived successfully`,
220
+ deletedCount
221
+ });
222
+ }
223
+ catch (error) {
224
+ res.status(500).json({ error: error.message });
225
+ }
226
+ });
116
227
  // Keep PUT for backward compatibility if needed, or remove.
117
228
  // Tasks says Implement POST. I'll remove PUT to avoid confusion or redirect it.
118
229
  router.put('/config', async (req, res) => {
@@ -0,0 +1,55 @@
1
+ import { Santi } from "../santi/santi.js";
2
+ import { ConfigManager } from "../../config/manager.js";
3
+ import * as path from "path";
4
+ import { homedir } from "os";
5
+ async function main() {
6
+ console.log("Starting Santi Manual Verification...");
7
+ // 1. Check DB isolation
8
+ const santiDbPath = path.join(homedir(), ".morpheus", "memory", "santi-memory.db");
9
+ const shortDbPath = path.join(homedir(), ".morpheus", "memory", "short-memory.db");
10
+ console.log(`Checking DB paths: \n- ${santiDbPath}\n- ${shortDbPath}`);
11
+ // 2. Initialize Santi
12
+ const santi = new Santi();
13
+ console.log("Santi initialized.");
14
+ // 3. Test Recovery (should be empty or have data if prev run)
15
+ let memories = await santi.recover("test");
16
+ console.log(`Initial recovery "test": ${memories.length} items`);
17
+ // 4. Manually add memory via internal store (reflection hack for test)
18
+ console.log("Injecting test memory...");
19
+ // @ts-ignore
20
+ santi.store.addMemory({
21
+ category: 'preference',
22
+ importance: 'high',
23
+ summary: 'The user loves manual testing scripts.',
24
+ hash: 'manual-test-hash',
25
+ source: 'manual_test'
26
+ });
27
+ // 5. Test Recovery again
28
+ memories = await santi.recover("loves manual testing");
29
+ console.log(`Recovery after injection "loves manual testing": ${memories.length} items`);
30
+ if (memories.length > 0 && memories[0].summary.includes("loves manual testing")) {
31
+ console.log("✅ Recovery SUCCESS");
32
+ }
33
+ else {
34
+ console.error("❌ Recovery FAILED");
35
+ }
36
+ // 6. Test Evaluate (Mocking messages)
37
+ // This requires LLM config to be present.
38
+ // We skip this in manual test if no config, but logging will show warning.
39
+ if (ConfigManager.getInstance().get().llm) {
40
+ console.log("Testing Evaluate (dry run with LLM)...");
41
+ // This will assume valid LLM config
42
+ /*
43
+ await santi.evaluate([
44
+ new HumanMessage("My name is Morpheus Tester."),
45
+ new AIMessage("Nice to meet you.")
46
+ ]);
47
+ */
48
+ console.log("Evaluate test skipped to avoid api cost, but code is in place.");
49
+ }
50
+ else {
51
+ console.log("Skipping Evaluate test (no LLM config).");
52
+ }
53
+ console.log("Verification Complete.");
54
+ }
55
+ main().catch(console.error);
@@ -80,6 +80,9 @@ export class DisplayManager {
80
80
  else if (options.source === 'Oracle') {
81
81
  color = chalk.hex('#FFA500');
82
82
  }
83
+ else if (options.source === 'Sati') {
84
+ color = chalk.hex('#00ff22');
85
+ }
83
86
  else if (options.source === 'Telephonist') {
84
87
  color = chalk.hex('#b902b9');
85
88
  }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { SatiRepository } from '../repository.js';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ describe('SatiRepository', () => {
6
+ const dbPath = path.join(process.cwd(), 'test-memory.db');
7
+ let repo;
8
+ beforeEach(() => {
9
+ // Reset singleton instance for testing
10
+ SatiRepository.instance = null;
11
+ repo = SatiRepository.getInstance(dbPath);
12
+ repo.initialize();
13
+ });
14
+ afterEach(() => {
15
+ try {
16
+ repo.close();
17
+ }
18
+ catch (e) { }
19
+ if (fs.existsSync(dbPath)) {
20
+ try {
21
+ fs.unlinkSync(dbPath);
22
+ fs.rmdirSync(path.dirname(dbPath)); // cleanup dir if possible/empty
23
+ }
24
+ catch (e) { }
25
+ }
26
+ });
27
+ it('should save and retrieve memory', async () => {
28
+ const mem = {
29
+ category: 'preference',
30
+ importance: 'high',
31
+ summary: 'Test Memory',
32
+ hash: '123',
33
+ source: 'test'
34
+ };
35
+ await repo.save(mem);
36
+ const result = repo.findByHash('123');
37
+ expect(result).not.toBeNull();
38
+ expect(result?.summary).toBe('Test Memory');
39
+ });
40
+ it('should deduplicate (update) on hash collision', async () => {
41
+ const mem1 = {
42
+ category: 'preference',
43
+ importance: 'medium',
44
+ summary: 'Test Memory v1',
45
+ hash: 'abc',
46
+ source: 'test'
47
+ };
48
+ await repo.save(mem1);
49
+ const mem2 = {
50
+ category: 'preference',
51
+ importance: 'high',
52
+ summary: 'Test Memory v2', // Summary logic might not update summary field in my query
53
+ // Let's check my query in repository.ts:
54
+ // ON CONFLICT(hash) DO UPDATE SET importance..., details... but NOT summary.
55
+ // If hash is same, summary implies same content usually.
56
+ // But here I test that fields ARE updated.
57
+ hash: 'abc',
58
+ source: 'test',
59
+ details: 'updated details'
60
+ };
61
+ await repo.save(mem2);
62
+ const result = repo.findByHash('abc');
63
+ expect(result).not.toBeNull();
64
+ expect(result?.importance).toBe('high');
65
+ expect(result?.details).toBe('updated details');
66
+ // Since my query did not update summary (it assumed hash=summary match), summary should stay v1.
67
+ expect(result?.summary).toBe('Test Memory v1');
68
+ // Access count should increment
69
+ expect(result?.access_count).toBeGreaterThan(0);
70
+ });
71
+ });
@@ -0,0 +1,99 @@
1
+ import { vi, describe, it, expect, beforeEach } from 'vitest';
2
+ import { SatiService } from '../service.js';
3
+ import { SatiRepository } from '../repository.js';
4
+ import { ProviderFactory } from '../../../providers/factory.js';
5
+ // Mock ConfigManager
6
+ vi.mock('../../../../config/manager.js', () => ({
7
+ ConfigManager: {
8
+ getInstance: vi.fn().mockReturnValue({
9
+ get: vi.fn().mockReturnValue({ llm: { provider: 'test' } })
10
+ })
11
+ }
12
+ }));
13
+ // Mock ProviderFactory
14
+ vi.mock('../../../providers/factory.js', () => ({
15
+ ProviderFactory: {
16
+ create: vi.fn()
17
+ }
18
+ }));
19
+ // Mock the repository module
20
+ vi.mock('../repository.js', () => {
21
+ const SatiRepositoryMock = {
22
+ getInstance: vi.fn(),
23
+ };
24
+ return { SatiRepository: SatiRepositoryMock };
25
+ });
26
+ describe('SatiService', () => {
27
+ let service;
28
+ let mockRepo;
29
+ let mockAgent;
30
+ beforeEach(() => {
31
+ vi.resetAllMocks();
32
+ // Setup mock repository instance
33
+ mockRepo = {
34
+ initialize: vi.fn(),
35
+ search: vi.fn(),
36
+ save: vi.fn(),
37
+ getAllMemories: vi.fn().mockReturnValue([]),
38
+ };
39
+ SatiRepository.getInstance.mockReturnValue(mockRepo);
40
+ // Setup mock agent
41
+ mockAgent = {
42
+ invoke: vi.fn()
43
+ };
44
+ ProviderFactory.create.mockResolvedValue(mockAgent);
45
+ service = SatiService.getInstance();
46
+ service.repository = mockRepo;
47
+ });
48
+ describe('recover', () => {
49
+ it('should recover memories calling repository with limit', async () => {
50
+ mockRepo.search.mockReturnValue([
51
+ { summary: 'Memory 1', category: 'preference', importance: 'high' },
52
+ { summary: 'Memory 2', category: 'project', importance: 'medium' }
53
+ ]);
54
+ const result = await service.recover('hello world', []);
55
+ expect(mockRepo.search).toHaveBeenCalledWith('hello world', 5);
56
+ expect(result.relevant_memories).toHaveLength(2);
57
+ expect(result.relevant_memories[0].summary).toBe('Memory 1');
58
+ });
59
+ it('should return empty list when no memories found', async () => {
60
+ mockRepo.search.mockReturnValue([]);
61
+ const result = await service.recover('unknown', []);
62
+ expect(mockRepo.search).toHaveBeenCalledWith('unknown', 5);
63
+ expect(result.relevant_memories).toEqual([]);
64
+ });
65
+ });
66
+ describe('evaluateAndPersist', () => {
67
+ it('should parse LLM response and persist memory', async () => {
68
+ mockAgent.invoke.mockResolvedValue({
69
+ messages: [{
70
+ content: JSON.stringify({
71
+ should_store: true,
72
+ category: 'preference',
73
+ importance: 'high',
74
+ summary: 'User likes TypeScript',
75
+ reason: 'User stated preference'
76
+ })
77
+ }]
78
+ });
79
+ await service.evaluateAndPersist([{ role: 'user', content: 'I like TypeScript' }]);
80
+ expect(mockRepo.save).toHaveBeenCalledWith(expect.objectContaining({
81
+ summary: 'User likes TypeScript',
82
+ category: 'preference',
83
+ importance: 'high'
84
+ }));
85
+ });
86
+ it('should not persist if should_store is false', async () => {
87
+ mockAgent.invoke.mockResolvedValue({
88
+ messages: [{
89
+ content: JSON.stringify({
90
+ should_store: false,
91
+ reason: 'Chit chat'
92
+ })
93
+ }]
94
+ });
95
+ await service.evaluateAndPersist([{ role: 'user', content: 'Hi' }]);
96
+ expect(mockRepo.save).not.toHaveBeenCalled();
97
+ });
98
+ });
99
+ });