tibiaway-ai 1.0.0 → 1.0.2

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.
@@ -5,6 +5,21 @@ description: Expert Tibia game assistant — hunting spots, profit calculations,
5
5
 
6
6
  You are TibiaWayAI, an expert assistant for the MMORPG Tibia with over 20 years of in-depth game knowledge. You know every mechanic, creature, quest, boss, vocation, and the current meta.
7
7
 
8
+ ## First Message — Character Setup
9
+
10
+ At the very start of each conversation, before answering anything else:
11
+
12
+ 1. Check if a file `~/.tibiaway/config.json` exists and read it (use available tools if possible).
13
+ - If it exists, extract: `character.name`, `character.vocation`, `character.level`, `character.world`, `character.guild`
14
+ - Greet the player by name and confirm their profile: "¡Hola [nombre]! Veo que eres [vocación] level [X] en [world]. ¿En qué te ayudo?"
15
+
16
+ 2. If the file does not exist or cannot be read, ask once:
17
+ > "¡Hola! Para darte recomendaciones personalizadas, dime: ¿cuál es tu personaje? (nombre, vocación y level)"
18
+
19
+ Accept informal input like "soy ed 200" or "knight lv 150 en Antica" and extract the data from it.
20
+
21
+ 3. Store the character data in your context for the entire conversation. Never ask again.
22
+
8
23
  ## Personality
9
24
  - Veteran Tibia player: direct, precise, with community humor
10
25
  - Never condescending — treat the player as an equal
@@ -26,7 +41,7 @@ Use these slash commands:
26
41
 
27
42
  ## Context
28
43
 
29
- When a player profile is available, always personalize responses based on:
44
+ Always personalize responses based on the player's profile:
30
45
  - Vocation (affects spell rotation, equipment, and hunting style)
31
46
  - Level (determines accessible areas and viable spots)
32
47
  - World (affects market prices and PvP rules)
@@ -2,9 +2,9 @@
2
2
  description: Analyze Tibia screenshots or hunt notes to extract insights and track progress
3
3
  ---
4
4
 
5
- Analyze a Tibia screenshot or hunt notes file.
5
+ Analyze a Tibia screenshot or hunt notes file. Use the character profile from the conversation context. If no profile is available yet, ask for it first.
6
6
 
7
- Supported formats: .png, .jpg, .jpeg (screenshots), .txt, .md (notes)
7
+ The player should attach the image or paste the text directly in the conversation.
8
8
 
9
9
  For **screenshots**, identify and analyze:
10
10
  - Loot screenshot → list visible items, estimate total gold value
@@ -13,11 +13,11 @@ For **screenshots**, identify and analyze:
13
13
  - Stats/skills/equipment → analyze and give recommendations
14
14
  - Chat/messages → analyze context and give suggestions
15
15
 
16
- For **text files**, extract:
16
+ For **pasted text/notes**, extract:
17
17
  - Key metrics (exp, gold, loot, time, supplies)
18
18
  - Main insights from the session
19
19
  - Improvement opportunities
20
20
  - Personalized recommendations for the player's profile
21
21
 
22
- Always end with a 1-2 line diary note summary.
22
+ Always end with a 1-2 line summary the player can save as a diary note.
23
23
  Respond in Spanish.
package/commands/boss.md CHANGED
@@ -2,7 +2,7 @@
2
2
  description: Detailed boss mechanics, phases, strategy by vocation, and loot information
3
3
  ---
4
4
 
5
- Provide complete information about a Tibia boss.
5
+ Provide complete information about a Tibia boss. Use the character profile from the conversation context for vocation-specific advice. If no profile is available yet, ask for it first.
6
6
 
7
7
  If no boss name was provided, ask: "¿Qué boss quieres enfrentar?"
8
8
 
package/commands/diary.md CHANGED
@@ -2,13 +2,15 @@
2
2
  description: Manage and analyze the player's Tibia progress diary
3
3
  ---
4
4
 
5
- Help the player manage their Tibia progress diary.
5
+ Help the player manage their Tibia progress diary. Use the character profile from the conversation context. If no profile is available yet, ask for it first.
6
6
 
7
7
  Subcommands:
8
8
  - `/diary add` — Add a new entry (ask for location, note, current level)
9
9
  - `/diary show` — Show recent diary history (last 20 entries)
10
10
  - `/diary summary` — Generate an AI-powered progress summary
11
11
 
12
+ If the user just writes `/diary` without a subcommand, ask what they want to do.
13
+
12
14
  For summary, analyze:
13
15
  1. Overall progress (levels gained, timeframe)
14
16
  2. Frequently visited spots and whether they're optimal
@@ -17,5 +19,8 @@ For summary, analyze:
17
19
  5. Recommendations based on history
18
20
  6. Suggested next objectives
19
21
 
22
+ Note: In OpenCode mode, diary entries are kept only within the current conversation session.
23
+ For persistent diary storage, use the CLI: `tibiaway diary`.
24
+
20
25
  Be analytical but encouraging. Mention areas for improvement with tact.
21
26
  Respond in Spanish.
package/commands/hunt.md CHANGED
@@ -2,7 +2,7 @@
2
2
  description: Suggest optimal hunting spots based on character profile, objective, and party setup
3
3
  ---
4
4
 
5
- Suggest the best hunting spots for $OPENCODE_CHARACTER_NAME (if known) or ask for level and vocation.
5
+ Suggest the best hunting spots for the player. Use the character profile from the conversation context (established at session start). If no profile is available yet, ask for it first.
6
6
 
7
7
  Ask the player:
8
8
  1. Objective: exp / loot / profit / task
@@ -2,7 +2,7 @@
2
2
  description: Calculate profit, waste, and experience per hour for a hunting session
3
3
  ---
4
4
 
5
- Calculate profit and waste for a Tibia hunting session.
5
+ Calculate profit and waste for a Tibia hunting session. Use the character profile from the conversation context. If no profile is available yet, ask for it first.
6
6
 
7
7
  Ask the player:
8
8
  1. Where did they hunt?
package/commands/quest.md CHANGED
@@ -2,7 +2,7 @@
2
2
  description: Recommend quests based on level, vocation, and player objectives
3
3
  ---
4
4
 
5
- Recommend Tibia quests for the player's current character.
5
+ Recommend Tibia quests for the player. Use the character profile from the conversation context. If no profile is available yet, ask for it first.
6
6
 
7
7
  Ask the player:
8
8
  1. What are they looking for? (zone access / outfit / achievement / equipment reward / story)
package/commands/set.md CHANGED
@@ -2,7 +2,7 @@
2
2
  description: Build optimal equipment sets with imbuements and supply recommendations
3
3
  ---
4
4
 
5
- Build the optimal equipment set for the player's character.
5
+ Build the optimal equipment set for the player's character. Use the character profile from the conversation context. If no profile is available yet, ask for it first.
6
6
 
7
7
  Ask the player:
8
8
  1. Situation: hunting / boss / pvp / training / specific quest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tibiaway-ai",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Tu guía de Tibia powered by AI. Your path through Tibia, powered by AI.",
5
5
  "bin": {
6
6
  "tibiaway": "./bin/tibiaway.js",
@@ -26,6 +26,7 @@
26
26
  "chalk": "^5.3.0",
27
27
  "inquirer": "^9.2.0",
28
28
  "node-fetch": "^3.3.0",
29
+ "openai": "^4.47.0",
29
30
  "ora": "^7.0.0"
30
31
  },
31
32
  "scripts": {
@@ -0,0 +1,108 @@
1
+ import { existsSync, mkdirSync, copyFileSync, readdirSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join, resolve, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import inquirer from 'inquirer';
6
+ import chalk from 'chalk';
7
+ import { success, error, info, highlight, separator } from '../utils/banner.js';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const PROJECT_ROOT = resolve(__dirname, '..', '..');
11
+
12
+ const DESTINATIONS = {
13
+ global: join(homedir(), '.config', 'opencode'),
14
+ local: join(process.cwd(), '.opencode'),
15
+ };
16
+
17
+ export async function handleSetupOpencode() {
18
+ console.log(chalk.hex('#FF6B1A').bold('🔌 SETUP OPENCODE\n'));
19
+ info('Instala TibiaWayAI como agente y comandos en OpenCode.');
20
+ info('No necesitas npm install — OpenCode usa su propio modelo de IA.');
21
+ console.log('');
22
+
23
+ const { destination } = await inquirer.prompt([
24
+ {
25
+ type: 'list',
26
+ name: 'destination',
27
+ message: highlight('¿Dónde quieres instalar el agente?'),
28
+ choices: [
29
+ {
30
+ name: `${chalk.white('Global')} ${chalk.hex('#888')(`(${join(homedir(), '.config/opencode/')}) — disponible en todos los proyectos`)}`,
31
+ value: 'global',
32
+ },
33
+ {
34
+ name: `${chalk.white('Local')} ${chalk.hex('#888')('(.opencode/ en este directorio) — solo en este proyecto')}`,
35
+ value: 'local',
36
+ },
37
+ ],
38
+ },
39
+ ]);
40
+
41
+ const baseDir = DESTINATIONS[destination];
42
+ const agentsDir = join(baseDir, 'agents');
43
+ const commandsDir = join(baseDir, 'commands');
44
+
45
+ // Crear directorios si no existen
46
+ for (const dir of [agentsDir, commandsDir]) {
47
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
48
+ }
49
+
50
+ const installed = [];
51
+ const skipped = [];
52
+
53
+ // Copiar agent
54
+ const agentSrc = join(PROJECT_ROOT, 'agents', 'tibiaway.md');
55
+ const agentDst = join(agentsDir, 'tibiaway.md');
56
+ if (existsSync(agentSrc)) {
57
+ copyFileSync(agentSrc, agentDst);
58
+ installed.push(`agents/tibiaway.md → ${agentDst}`);
59
+ }
60
+
61
+ // Copiar todos los comandos
62
+ const commandsSourceDir = join(PROJECT_ROOT, 'commands');
63
+ if (existsSync(commandsSourceDir)) {
64
+ for (const file of readdirSync(commandsSourceDir)) {
65
+ if (!file.endsWith('.md')) continue;
66
+ const src = join(commandsSourceDir, file);
67
+ const dst = join(commandsDir, file);
68
+ copyFileSync(src, dst);
69
+ installed.push(`commands/${file} → ${dst}`);
70
+ }
71
+ }
72
+
73
+ if (installed.length === 0) {
74
+ error('No se encontraron archivos para instalar. ¿Estás en el directorio correcto?');
75
+ return;
76
+ }
77
+
78
+ console.log('');
79
+ separator();
80
+ console.log('');
81
+ console.log(chalk.hex('#FF6B1A').bold('✓ Archivos instalados:\n'));
82
+ for (const f of installed) {
83
+ console.log(` ${chalk.hex('#4ade80')('✓')} ${chalk.hex('#888')(f)}`);
84
+ }
85
+
86
+ console.log('');
87
+ separator();
88
+ console.log('');
89
+ console.log(chalk.hex('#FF6B1A').bold('¡Listo! Así lo usas en OpenCode:\n'));
90
+
91
+ const steps = [
92
+ ['1.', 'Abre OpenCode en cualquier directorio'],
93
+ ['2.', 'Selecciona el agente TibiaWayAI en la lista de agentes'],
94
+ ['3.', 'Escribe tu personaje si te lo pide (nombre, vocación, level)'],
95
+ ['4.', 'Usa los comandos:'],
96
+ ];
97
+ for (const [n, s] of steps) {
98
+ console.log(` ${chalk.hex('#FF6B1A')(n)} ${chalk.white(s)}`);
99
+ }
100
+
101
+ const cmds = ['/hunt', '/profit', '/quest', '/set', '/boss', '/diary', '/analyze'];
102
+ console.log('');
103
+ console.log(' ' + cmds.map(c => chalk.hex('#FF6B1A')(c)).join(' '));
104
+ console.log('');
105
+
106
+ info('OpenCode usa su propio modelo de IA configurado — no necesitas API key adicional.');
107
+ console.log('');
108
+ }
package/src/installer.js CHANGED
@@ -1,64 +1,219 @@
1
1
  import inquirer from 'inquirer';
2
2
  import chalk from 'chalk';
3
- import { showBanner, success, error, info, highlight, separator } from './utils/banner.js';
3
+ import fetch from 'node-fetch';
4
+ import { showBanner, success, error, info, highlight, separator, warn } from './utils/banner.js';
4
5
  import { setupCharacter } from './utils/character.js';
5
- import { setApiKey, getApiKey, writeConfig, readConfig } from './utils/config.js';
6
+ import { setApiKey, getApiKey, setOllamaConfig, getOllamaConfig, getProvider, writeConfig, readConfig } from './utils/config.js';
7
+
8
+ const OLLAMA_MODELS = [
9
+ { name: 'llama3.2 (Recomendado · 3 GB · rápido y equilibrado)', value: 'llama3.2' },
10
+ { name: 'mistral (Buena calidad · 4 GB)', value: 'mistral' },
11
+ { name: 'qwen2.5 (Excelente en español · 4 GB)', value: 'qwen2.5' },
12
+ { name: 'deepseek-r1 (Razonamiento avanzado · 7 GB)', value: 'deepseek-r1' },
13
+ { name: 'Otro (ingresar nombre manualmente)', value: '__custom__' },
14
+ ];
15
+
16
+ async function checkOllamaConnection(url) {
17
+ try {
18
+ const res = await fetch(`${url}/api/tags`, { timeout: 4000 });
19
+ return res.ok;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function setupAnthropic(existingKey) {
26
+ if (existingKey) {
27
+ success('API key de Anthropic ya configurada.');
28
+ return;
29
+ }
30
+
31
+ console.log('');
32
+ info('Necesitas una API key de Anthropic para usar este proveedor.');
33
+ info(`Obtén la tuya en: ${chalk.hex('#FF6B1A')('https://console.anthropic.com')}`);
34
+ console.log('');
35
+
36
+ const { key } = await inquirer.prompt([
37
+ {
38
+ type: 'password',
39
+ name: 'key',
40
+ message: highlight('Ingresa tu Anthropic API key:'),
41
+ mask: '*',
42
+ validate: (input) => {
43
+ if (!input.trim()) return 'La API key no puede estar vacía.';
44
+ if (!input.trim().startsWith('sk-')) return 'La API key debe empezar con "sk-".';
45
+ return true;
46
+ },
47
+ },
48
+ ]);
49
+
50
+ setApiKey(key.trim());
51
+ success('API key guardada correctamente.');
52
+ }
53
+
54
+ async function setupOllama() {
55
+ console.log('');
56
+ info(`Ollama corre modelos de IA ${chalk.hex('#4ade80').bold('gratis')} en tu propia computadora.`);
57
+ info(`Si no lo tienes instalado, descárgalo en: ${chalk.hex('#FF6B1A')('https://ollama.com')}`);
58
+ info(`Luego instala el modelo con: ${chalk.hex('#FF6B1A')('ollama pull llama3.2')}`);
59
+ console.log('');
60
+
61
+ // Elegir modelo
62
+ const { modelChoice } = await inquirer.prompt([
63
+ {
64
+ type: 'list',
65
+ name: 'modelChoice',
66
+ message: highlight('¿Qué modelo de Ollama quieres usar?'),
67
+ choices: OLLAMA_MODELS,
68
+ },
69
+ ]);
70
+
71
+ let model = modelChoice;
72
+ if (modelChoice === '__custom__') {
73
+ const { customModel } = await inquirer.prompt([
74
+ {
75
+ type: 'input',
76
+ name: 'customModel',
77
+ message: highlight('Nombre del modelo (ej: phi3, gemma2):'),
78
+ validate: (i) => i.trim().length > 0 || 'Ingresa un nombre de modelo.',
79
+ },
80
+ ]);
81
+ model = customModel.trim();
82
+ }
83
+
84
+ // URL del servidor Ollama
85
+ const { url } = await inquirer.prompt([
86
+ {
87
+ type: 'input',
88
+ name: 'url',
89
+ message: highlight('URL del servidor Ollama:'),
90
+ default: 'http://localhost:11434',
91
+ },
92
+ ]);
93
+
94
+ const ollamaUrl = url.trim().replace(/\/$/, '');
95
+
96
+ // Verificar conexión
97
+ const spinner = (await import('ora')).default({ text: 'Verificando conexión a Ollama...', color: 'yellow' }).start();
98
+ const connected = await checkOllamaConnection(ollamaUrl);
99
+ spinner.stop();
100
+
101
+ if (!connected) {
102
+ warn(`No se pudo conectar a Ollama en ${ollamaUrl}`);
103
+ warn('Asegúrate de que Ollama esté corriendo: ollama serve');
104
+ console.log('');
105
+ const { continueAnyway } = await inquirer.prompt([
106
+ {
107
+ type: 'confirm',
108
+ name: 'continueAnyway',
109
+ message: highlight('¿Continuar de todas formas y configurar Ollama después?'),
110
+ default: true,
111
+ },
112
+ ]);
113
+ if (!continueAnyway) {
114
+ info('Puedes volver a configurar con: tibiaway setup');
115
+ process.exit(0);
116
+ }
117
+ } else {
118
+ success(`Ollama conectado en ${ollamaUrl}`);
119
+ }
120
+
121
+ setOllamaConfig({ model, url: ollamaUrl });
122
+ success(`Modelo configurado: ${highlight(model)}`);
123
+ }
6
124
 
7
125
  export async function runInstaller() {
8
126
  showBanner();
9
-
10
127
  console.log(chalk.hex('#f0f0f0')('Bienvenido a TibiaWayAI. Vamos a configurar tu asistente.\n'));
11
128
  separator();
12
129
  console.log('');
13
130
 
14
- // Step 1: API Key
15
- const existingKey = getApiKey();
16
- let apiKey = existingKey;
131
+ // ── PASO 1: Elegir proveedor ──────────────────────────────────────────────
132
+ const currentProvider = getProvider();
17
133
 
18
- if (!existingKey) {
19
- info('Necesitas una API key de Anthropic para usar TibiaWayAI.');
20
- info('Obtén la tuya en: https://console.anthropic.com');
21
- console.log('');
134
+ if (currentProvider) {
135
+ const providerLabel = currentProvider === 'anthropic'
136
+ ? chalk.hex('#FF6B1A')('Anthropic Claude')
137
+ : chalk.hex('#4ade80')('Ollama (local)');
138
+ success(`Proveedor ya configurado: ${providerLabel}`);
22
139
 
23
- const { key } = await inquirer.prompt([
140
+ const { change } = await inquirer.prompt([
24
141
  {
25
- type: 'password',
26
- name: 'key',
27
- message: highlight('Ingresa tu Anthropic API key:'),
28
- mask: '*',
29
- validate: (input) => {
30
- if (!input.trim()) return 'La API key no puede estar vacía.';
31
- if (!input.trim().startsWith('sk-')) return 'La API key debe empezar con "sk-".';
32
- return true;
33
- },
142
+ type: 'confirm',
143
+ name: 'change',
144
+ message: highlight('¿Quieres cambiar el proveedor de IA?'),
145
+ default: false,
34
146
  },
35
147
  ]);
36
148
 
37
- apiKey = key.trim();
38
- setApiKey(apiKey);
39
- success('API key guardada correctamente.');
149
+ if (!change) {
150
+ await continueSetup();
151
+ return;
152
+ }
40
153
  console.log('');
154
+ }
155
+
156
+ const { provider } = await inquirer.prompt([
157
+ {
158
+ type: 'list',
159
+ name: 'provider',
160
+ message: highlight('¿Qué proveedor de IA quieres usar?'),
161
+ choices: [
162
+ {
163
+ name: `${chalk.hex('#FF6B1A').bold('Anthropic Claude')} ${chalk.hex('#888')('· Mejor calidad · ~$0.01/consulta · Requiere API key')}`,
164
+ value: 'anthropic',
165
+ },
166
+ {
167
+ name: `${chalk.hex('#4ade80').bold('Ollama (local)')} ${chalk.hex('#888')('· Completamente gratis · Corre en tu PC · Sin internet')}`,
168
+ value: 'ollama',
169
+ },
170
+ ],
171
+ },
172
+ ]);
173
+
174
+ console.log('');
175
+ separator();
176
+
177
+ if (provider === 'anthropic') {
178
+ await setupAnthropic(getApiKey());
41
179
  } else {
42
- success('API key ya configurada.');
43
- console.log('');
180
+ await setupOllama();
44
181
  }
45
182
 
46
- // Step 2: Character setup
183
+ await continueSetup();
184
+ }
185
+
186
+ async function continueSetup() {
187
+ // ── PASO 2: Personaje ─────────────────────────────────────────────────────
47
188
  const config = readConfig();
189
+
48
190
  if (!config.character) {
191
+ console.log('');
49
192
  separator();
50
193
  console.log('');
51
194
  info('Ahora vamos a vincular tu personaje de Tibia.');
52
195
  console.log('');
53
196
  await setupCharacter();
54
197
  } else {
198
+ console.log('');
55
199
  success(`Personaje ya configurado: ${highlight(config.character.name)} (${config.character.vocation} lv ${config.character.level})`);
56
200
  }
57
201
 
202
+ // ── LISTO ─────────────────────────────────────────────────────────────────
58
203
  console.log('');
59
204
  separator();
60
205
  console.log('');
61
206
  console.log(chalk.hex('#FF6B1A').bold('¡TibiaWayAI está listo!'));
207
+ console.log('');
208
+
209
+ const provider = getProvider();
210
+ if (provider === 'ollama') {
211
+ const { model } = getOllamaConfig();
212
+ info(`Usando Ollama con el modelo ${highlight(model)}`);
213
+ } else {
214
+ info('Usando Anthropic Claude (claude-sonnet-4-20250514)');
215
+ }
216
+
62
217
  console.log('');
63
218
  console.log(chalk.hex('#f0f0f0')('Comandos disponibles:'));
64
219
  const cmds = [
package/src/standalone.js CHANGED
@@ -10,6 +10,7 @@ import { handleBoss } from './commands/boss.js';
10
10
  import { handleDiary } from './commands/diary.js';
11
11
  import { handleAnalyze } from './commands/analyze.js';
12
12
  import { handleSync } from './commands/sync.js';
13
+ import { handleSetupOpencode } from './commands/setup-opencode.js';
13
14
 
14
15
  const COMMANDS = {
15
16
  hunt: handleHunt,
@@ -22,6 +23,8 @@ const COMMANDS = {
22
23
  sync: handleSync,
23
24
  setup: runInstaller,
24
25
  install: runInstaller,
26
+ 'setup-opencode': handleSetupOpencode,
27
+ opencode: handleSetupOpencode,
25
28
  };
26
29
 
27
30
  function printWelcome() {
@@ -63,6 +66,7 @@ function printHelp() {
63
66
  ['analyze <archivo>', 'Analiza screenshot o notas (.png, .jpg, .txt)'],
64
67
  ['sync', 'Actualiza tu perfil desde tibiadata.com'],
65
68
  ['setup', 'Configuración inicial (API key + personaje)'],
69
+ ['setup-opencode', 'Instala el agente en OpenCode (sin npm install)'],
66
70
  ];
67
71
 
68
72
  for (const [cmd, desc] of cmds) {
@@ -102,6 +106,12 @@ export async function run(args) {
102
106
  return;
103
107
  }
104
108
 
109
+ // setup-opencode no requiere tener el CLI configurado
110
+ if (command === 'setup-opencode' || command === 'opencode') {
111
+ await handleSetupOpencode();
112
+ return;
113
+ }
114
+
105
115
  // Auto-setup si falta config y no es sync
106
116
  if (!isConfigured() && command !== 'sync') {
107
117
  info('Primera vez que ejecutas TibiaWayAI. Vamos a configurarlo.');
package/src/utils/api.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
+ import OpenAI from 'openai';
2
3
  import { readFileSync } from 'fs';
3
- import { getApiKey, getCharacter } from './config.js';
4
- import { error } from './banner.js';
4
+ import { getApiKey, getCharacter, getProvider, getOllamaConfig } from './config.js';
5
+ import { error, warn } from './banner.js';
5
6
 
6
- const MODEL = 'claude-sonnet-4-20250514';
7
+ const ANTHROPIC_MODEL = 'claude-sonnet-4-20250514';
7
8
  const MAX_TOKENS = 2048;
8
9
 
9
10
  function buildSystemPrompt(extraContext = '') {
@@ -33,32 +34,66 @@ Tienes personalidad de veterano tibian: directo, preciso, con humor de la comuni
33
34
  ${extraContext}`;
34
35
  }
35
36
 
36
- function getClient() {
37
+ function getAnthropicClient() {
37
38
  const apiKey = getApiKey();
38
39
  if (!apiKey) {
39
40
  error('No se encontró API key de Anthropic.');
40
- console.error('Configura tu API key con: tibiaway setup');
41
- console.error('O exporta la variable: export ANTHROPIC_API_KEY=tu_key');
41
+ console.error('Reconfigura con: tibiaway setup');
42
42
  process.exit(1);
43
43
  }
44
44
  return new Anthropic({ apiKey });
45
45
  }
46
46
 
47
+ function getOllamaClient() {
48
+ const { url } = getOllamaConfig();
49
+ return new OpenAI({
50
+ baseURL: `${url}/v1`,
51
+ apiKey: 'ollama',
52
+ });
53
+ }
54
+
55
+ function getClient() {
56
+ const provider = getProvider();
57
+ if (provider === 'ollama') return { type: 'ollama', client: getOllamaClient() };
58
+ if (provider === 'anthropic') return { type: 'anthropic', client: getAnthropicClient() };
59
+
60
+ error('No hay proveedor de IA configurado. Ejecuta: tibiaway setup');
61
+ process.exit(1);
62
+ }
63
+
47
64
  export async function askClaude(userMessage, extraContext = '') {
48
- const client = getClient();
65
+ const { type, client } = getClient();
66
+ const systemPrompt = buildSystemPrompt(extraContext);
67
+
68
+ if (type === 'anthropic') {
69
+ const response = await client.messages.create({
70
+ model: ANTHROPIC_MODEL,
71
+ max_tokens: MAX_TOKENS,
72
+ system: systemPrompt,
73
+ messages: [{ role: 'user', content: userMessage }],
74
+ });
75
+ return response.content[0]?.text || '';
76
+ }
49
77
 
50
- const response = await client.messages.create({
51
- model: MODEL,
52
- max_tokens: MAX_TOKENS,
53
- system: buildSystemPrompt(extraContext),
54
- messages: [{ role: 'user', content: userMessage }],
78
+ // Ollama usa API compatible con OpenAI
79
+ const { model } = getOllamaConfig();
80
+ const response = await client.chat.completions.create({
81
+ model,
82
+ messages: [
83
+ { role: 'system', content: systemPrompt },
84
+ { role: 'user', content: userMessage },
85
+ ],
55
86
  });
56
-
57
- return response.content[0]?.text || '';
87
+ return response.choices[0]?.message?.content || '';
58
88
  }
59
89
 
60
90
  export async function askClaudeWithImage(userMessage, imagePath, extraContext = '') {
61
- const client = getClient();
91
+ const { type, client } = getClient();
92
+
93
+ if (type === 'ollama') {
94
+ warn('El análisis de imágenes no está disponible con Ollama. Procesando solo el texto...');
95
+ return askClaude(`[El usuario quería analizar una imagen en ${imagePath}, pero Ollama no soporta visión en este momento]\n\n${userMessage}`, extraContext);
96
+ }
62
97
 
63
98
  const imageBuffer = readFileSync(imagePath);
64
99
  const base64Image = imageBuffer.toString('base64');
@@ -67,17 +102,14 @@ export async function askClaudeWithImage(userMessage, imagePath, extraContext =
67
102
  const mediaType = mediaTypeMap[ext] || 'image/png';
68
103
 
69
104
  const response = await client.messages.create({
70
- model: MODEL,
105
+ model: ANTHROPIC_MODEL,
71
106
  max_tokens: MAX_TOKENS,
72
107
  system: buildSystemPrompt(extraContext),
73
108
  messages: [
74
109
  {
75
110
  role: 'user',
76
111
  content: [
77
- {
78
- type: 'image',
79
- source: { type: 'base64', media_type: mediaType, data: base64Image },
80
- },
112
+ { type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Image } },
81
113
  { type: 'text', text: userMessage },
82
114
  ],
83
115
  },
@@ -88,22 +120,43 @@ export async function askClaudeWithImage(userMessage, imagePath, extraContext =
88
120
  }
89
121
 
90
122
  export async function streamClaude(userMessage, extraContext = '', onChunk) {
91
- const client = getClient();
123
+ const { type, client } = getClient();
124
+ const systemPrompt = buildSystemPrompt(extraContext);
125
+
126
+ if (type === 'anthropic') {
127
+ const stream = client.messages.stream({
128
+ model: ANTHROPIC_MODEL,
129
+ max_tokens: MAX_TOKENS,
130
+ system: systemPrompt,
131
+ messages: [{ role: 'user', content: userMessage }],
132
+ });
133
+ let fullText = '';
134
+ for await (const chunk of stream) {
135
+ if (chunk.type === 'content_block_delta' && chunk.delta?.type === 'text_delta') {
136
+ fullText += chunk.delta.text;
137
+ if (onChunk) onChunk(chunk.delta.text);
138
+ }
139
+ }
140
+ return fullText;
141
+ }
92
142
 
93
- const stream = client.messages.stream({
94
- model: MODEL,
95
- max_tokens: MAX_TOKENS,
96
- system: buildSystemPrompt(extraContext),
97
- messages: [{ role: 'user', content: userMessage }],
143
+ // Ollama streaming
144
+ const { model } = getOllamaConfig();
145
+ const stream = await client.chat.completions.create({
146
+ model,
147
+ stream: true,
148
+ messages: [
149
+ { role: 'system', content: systemPrompt },
150
+ { role: 'user', content: userMessage },
151
+ ],
98
152
  });
99
-
100
153
  let fullText = '';
101
154
  for await (const chunk of stream) {
102
- if (chunk.type === 'content_block_delta' && chunk.delta?.type === 'text_delta') {
103
- fullText += chunk.delta.text;
104
- if (onChunk) onChunk(chunk.delta.text);
155
+ const text = chunk.choices[0]?.delta?.content || '';
156
+ if (text) {
157
+ fullText += text;
158
+ if (onChunk) onChunk(text);
105
159
  }
106
160
  }
107
-
108
161
  return fullText;
109
162
  }
@@ -6,8 +6,11 @@ const CONFIG_DIR = join(homedir(), '.tibiaway');
6
6
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
7
 
8
8
  const DEFAULT_CONFIG = {
9
- character: null,
9
+ provider: null,
10
10
  anthropic_api_key: null,
11
+ ollama_model: 'llama3.2',
12
+ ollama_url: 'http://localhost:11434',
13
+ character: null,
11
14
  language: 'español',
12
15
  diary: [],
13
16
  last_sync: null,
@@ -49,6 +52,19 @@ export function setCharacter(characterData) {
49
52
  writeConfig(config);
50
53
  }
51
54
 
55
+ export function getProvider() {
56
+ const config = readConfig();
57
+ // retrocompatibilidad: si hay api key guardada pero no provider, asumimos anthropic
58
+ if (!config.provider && config.anthropic_api_key) return 'anthropic';
59
+ return config.provider || null;
60
+ }
61
+
62
+ export function setProvider(provider) {
63
+ const config = readConfig();
64
+ config.provider = provider;
65
+ writeConfig(config);
66
+ }
67
+
52
68
  export function getApiKey() {
53
69
  return process.env.ANTHROPIC_API_KEY || readConfig().anthropic_api_key || null;
54
70
  }
@@ -56,16 +72,30 @@ export function getApiKey() {
56
72
  export function setApiKey(key) {
57
73
  const config = readConfig();
58
74
  config.anthropic_api_key = key;
75
+ config.provider = 'anthropic';
76
+ writeConfig(config);
77
+ }
78
+
79
+ export function getOllamaConfig() {
80
+ const config = readConfig();
81
+ return {
82
+ model: config.ollama_model || 'llama3.2',
83
+ url: config.ollama_url || 'http://localhost:11434',
84
+ };
85
+ }
86
+
87
+ export function setOllamaConfig({ model, url }) {
88
+ const config = readConfig();
89
+ config.ollama_model = model;
90
+ config.ollama_url = url;
91
+ config.provider = 'ollama';
59
92
  writeConfig(config);
60
93
  }
61
94
 
62
95
  export function addDiaryEntry(entry) {
63
96
  const config = readConfig();
64
97
  if (!Array.isArray(config.diary)) config.diary = [];
65
- config.diary.push({
66
- date: new Date().toISOString(),
67
- ...entry,
68
- });
98
+ config.diary.push({ date: new Date().toISOString(), ...entry });
69
99
  writeConfig(config);
70
100
  }
71
101
 
@@ -76,7 +106,11 @@ export function getDiary() {
76
106
 
77
107
  export function isConfigured() {
78
108
  const config = readConfig();
79
- return !!(config.character && (config.anthropic_api_key || process.env.ANTHROPIC_API_KEY));
109
+ if (!config.character) return false;
110
+ const provider = getProvider();
111
+ if (provider === 'ollama') return true;
112
+ if (provider === 'anthropic') return !!(config.anthropic_api_key || process.env.ANTHROPIC_API_KEY);
113
+ return false;
80
114
  }
81
115
 
82
116
  export function getConfigPath() {