@vibe-cafe/vibe-usage 0.7.6 → 0.7.8

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/README.md CHANGED
@@ -57,7 +57,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
57
57
  | Kimi Code | `~/.kimi/sessions/` |
58
58
  | Amp | `~/.local/share/amp/threads/` |
59
59
  | Droid | `~/.factory/sessions/` |
60
- | Hermes | `~/.hermes/state.db` (SQLite) |
60
+ | Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
61
61
 
62
62
  ## How It Works
63
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,6 +3,7 @@ import { writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { homedir, platform } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { success, failure, warn, dim } from './output.js';
6
7
 
7
8
  const SERVICE_NAME = 'vibe-usage';
8
9
  const LAUNCHD_LABEL = 'ai.vibecafe.vibe-usage';
@@ -109,24 +110,23 @@ function run(cmd, args) {
109
110
  function install() {
110
111
  const plat = detectPlatform();
111
112
  if (!plat) {
112
- console.log('Daemon install is not supported on this platform.');
113
- console.log('Supported: Linux (systemd), macOS (launchd).');
113
+ console.log(failure('当前平台不支持 daemon。'));
114
+ console.log(dim(' 支持: Linux (systemd) / macOS (launchd)'));
114
115
  return;
115
116
  }
116
117
 
117
118
  const { nodePath, binPath, isNpxCache } = resolvePaths();
118
119
 
119
120
  if (isNpxCache) {
120
- console.log('Warning: vibe-usage appears to be running from the npx cache.');
121
- console.log('The daemon may break when the cache is cleared.');
122
- console.log('For reliable operation, install globally first:');
123
- console.log(' npm install -g @vibe-cafe/vibe-usage\n');
121
+ console.log(warn('检测到从 npx 缓存运行 vibe-usage,缓存清理后 daemon 会失效。'));
122
+ console.log(dim(' 建议先全局安装: npm install -g @vibe-cafe/vibe-usage'));
123
+ console.log();
124
124
  }
125
125
 
126
126
  const paths = getServicePaths(plat);
127
127
 
128
128
  if (existsSync(paths.file)) {
129
- console.log('Service is already installed. Run `vibe-usage daemon restart` or `daemon uninstall` first.');
129
+ console.log(warn('Daemon 已安装,运行 `vibe-usage daemon restart` `uninstall` 先处理。'));
130
130
  return;
131
131
  }
132
132
 
@@ -134,45 +134,46 @@ function install() {
134
134
 
135
135
  if (plat === 'systemd') {
136
136
  writeFileSync(paths.file, generateSystemdUnit(nodePath, binPath), 'utf-8');
137
- console.log(`Created ${paths.file}`);
137
+ console.log(dim(` 已写入 ${paths.file}`));
138
138
 
139
139
  run('systemctl', ['--user', 'daemon-reload']);
140
140
  const result = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
141
141
  if (!result.ok) {
142
- console.error(`Failed to start service: ${result.output}`);
142
+ console.error(failure(`启动服务失败: ${result.output}`));
143
143
  return;
144
144
  }
145
- console.log('Service enabled and started.');
145
+ console.log(success('服务已启用并启动。'));
146
146
  }
147
147
 
148
148
  if (plat === 'launchd') {
149
149
  mkdirSync(join(homedir(), '.vibe-usage'), { recursive: true });
150
150
  writeFileSync(paths.file, generateLaunchdPlist(nodePath, binPath), 'utf-8');
151
- console.log(`Created ${paths.file}`);
151
+ console.log(dim(` 已写入 ${paths.file}`));
152
152
 
153
153
  const result = run('launchctl', ['load', paths.file]);
154
154
  if (!result.ok) {
155
- console.error(`Failed to load service: ${result.output}`);
155
+ console.error(failure(`加载服务失败: ${result.output}`));
156
156
  return;
157
157
  }
158
- console.log('Service loaded and started.');
158
+ console.log(success('服务已加载并启动。'));
159
159
  }
160
160
 
161
- console.log('\nDaemon installed. Usage data will sync automatically every 30 minutes.');
162
- console.log('Run `vibe-usage daemon status` to check.');
161
+ console.log();
162
+ console.log(success('Daemon 已安装,用量数据将每 30 分钟自动同步。'));
163
+ console.log(dim(' 运行 `vibe-usage daemon status` 查看状态。'));
163
164
  }
164
165
 
165
166
  function uninstall() {
166
167
  const plat = detectPlatform();
167
168
  if (!plat) {
168
- console.log('No supported service platform detected.');
169
+ console.log(failure('未检测到支持的服务平台。'));
169
170
  return;
170
171
  }
171
172
 
172
173
  const paths = getServicePaths(plat);
173
174
 
174
175
  if (!existsSync(paths.file)) {
175
- console.log('No daemon service is installed.');
176
+ console.log(dim('未安装 daemon 服务。'));
176
177
  return;
177
178
  }
178
179
 
@@ -181,43 +182,43 @@ function uninstall() {
181
182
  run('systemctl', ['--user', 'disable', `${SERVICE_NAME}.service`]);
182
183
  unlinkSync(paths.file);
183
184
  run('systemctl', ['--user', 'daemon-reload']);
184
- console.log('Service stopped, disabled, and removed.');
185
+ console.log(success('服务已停止、禁用并删除。'));
185
186
  }
186
187
 
187
188
  if (plat === 'launchd') {
188
189
  run('launchctl', ['unload', paths.file]);
189
190
  unlinkSync(paths.file);
190
- console.log('Service unloaded and removed.');
191
+ console.log(success('服务已卸载并删除。'));
191
192
  }
192
193
  }
193
194
 
194
195
  function status() {
195
196
  const plat = detectPlatform();
196
197
  if (!plat) {
197
- console.log('No supported service platform detected.');
198
+ console.log(failure('未检测到支持的服务平台。'));
198
199
  return;
199
200
  }
200
201
 
201
202
  const paths = getServicePaths(plat);
202
203
 
203
204
  if (!existsSync(paths.file)) {
204
- console.log('No daemon service is installed.');
205
- console.log('Run `vibe-usage daemon install` to set up.');
205
+ console.log(dim('未安装 daemon 服务。'));
206
+ console.log(dim(' 运行 `vibe-usage daemon install` 安装。'));
206
207
  return;
207
208
  }
208
209
 
209
210
  if (plat === 'systemd') {
210
211
  const result = run('systemctl', ['--user', 'status', `${SERVICE_NAME}.service`]);
211
- console.log(result.output);
212
+ console.log(dim(result.output));
212
213
  }
213
214
 
214
215
  if (plat === 'launchd') {
215
216
  const result = run('launchctl', ['list', LAUNCHD_LABEL]);
216
217
  if (result.ok) {
217
- console.log(`Service: ${LAUNCHD_LABEL}`);
218
- console.log(result.output);
218
+ console.log(dim(`Service: ${LAUNCHD_LABEL}`));
219
+ console.log(dim(result.output));
219
220
  } else {
220
- console.log('Service is installed but not currently running.');
221
+ console.log(warn('服务已安装但当前未运行。'));
221
222
  }
222
223
  }
223
224
  }
@@ -225,37 +226,37 @@ function status() {
225
226
  function stop() {
226
227
  const plat = detectPlatform();
227
228
  if (!plat) {
228
- console.log('No supported service platform detected.');
229
+ console.log(failure('未检测到支持的服务平台。'));
229
230
  return;
230
231
  }
231
232
 
232
233
  if (plat === 'systemd') {
233
234
  const result = run('systemctl', ['--user', 'stop', `${SERVICE_NAME}.service`]);
234
- console.log(result.ok ? 'Service stopped.' : `Failed: ${result.output}`);
235
+ console.log(result.ok ? success('服务已停止。') : failure(`停止失败: ${result.output}`));
235
236
  }
236
237
 
237
238
  if (plat === 'launchd') {
238
239
  const result = run('launchctl', ['stop', LAUNCHD_LABEL]);
239
- console.log(result.ok ? 'Service stopped.' : `Failed: ${result.output}`);
240
+ console.log(result.ok ? success('服务已停止。') : failure(`停止失败: ${result.output}`));
240
241
  }
241
242
  }
242
243
 
243
244
  function restart() {
244
245
  const plat = detectPlatform();
245
246
  if (!plat) {
246
- console.log('No supported service platform detected.');
247
+ console.log(failure('未检测到支持的服务平台。'));
247
248
  return;
248
249
  }
249
250
 
250
251
  if (plat === 'systemd') {
251
252
  const result = run('systemctl', ['--user', 'restart', `${SERVICE_NAME}.service`]);
252
- console.log(result.ok ? 'Service restarted.' : `Failed: ${result.output}`);
253
+ console.log(result.ok ? success('服务已重启。') : failure(`重启失败: ${result.output}`));
253
254
  }
254
255
 
255
256
  if (plat === 'launchd') {
256
257
  run('launchctl', ['stop', LAUNCHD_LABEL]);
257
258
  const result = run('launchctl', ['start', LAUNCHD_LABEL]);
258
- console.log(result.ok ? 'Service restarted.' : `Failed: ${result.output}`);
259
+ console.log(result.ok ? success('服务已重启。') : failure(`重启失败: ${result.output}`));
259
260
  }
260
261
  }
261
262
 
@@ -264,8 +265,8 @@ const SUBCOMMANDS = { install, uninstall, status, stop, restart };
264
265
  export async function manageDaemon(subcommand) {
265
266
  const fn = SUBCOMMANDS[subcommand];
266
267
  if (!fn) {
267
- console.error(`Unknown daemon subcommand: ${subcommand}`);
268
- console.error('Usage: vibe-usage daemon <install|uninstall|status|stop|restart>');
268
+ console.error(failure(`未知 daemon 子命令: ${subcommand}`));
269
+ console.error(dim(' 用法: vibe-usage daemon <install|uninstall|status|stop|restart>'));
269
270
  process.exit(1);
270
271
  }
271
272
  fn();
package/src/daemon.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { loadConfig } from './config.js';
2
2
  import { runSync } from './sync.js';
3
+ import { failure, dim } from './output.js';
3
4
 
4
5
  const INTERVAL = 30 * 60_000; // 30 minutes
5
6
 
6
7
  function log(msg) {
7
8
  const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
8
- process.stdout.write(`[${ts}] ${msg}\n`);
9
+ process.stdout.write(dim(`[${ts}] ${msg}\n`));
9
10
  }
10
11
 
11
12
  function sleep(ms) {
@@ -15,11 +16,11 @@ function sleep(ms) {
15
16
  export async function runDaemon() {
16
17
  const config = loadConfig();
17
18
  if (!config?.apiKey) {
18
- console.error('Not configured. Run `npx @vibe-cafe/vibe-usage init` first.');
19
+ console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
19
20
  process.exit(1);
20
21
  }
21
22
 
22
- log('Daemon started (sync every 30m, Ctrl+C to stop)');
23
+ log('daemon started (sync every 30m, Ctrl+C to stop)');
23
24
 
24
25
  // eslint-disable-next-line no-constant-condition
25
26
  while (true) {
@@ -27,10 +28,10 @@ export async function runDaemon() {
27
28
  await runSync({ throws: true, quiet: true });
28
29
  } catch (err) {
29
30
  if (err.message === 'UNAUTHORIZED') {
30
- log('API key invalid. Exiting.');
31
+ log('API key invalid, exiting.');
31
32
  process.exit(1);
32
33
  }
33
- log(`Sync error: ${err.message}`);
34
+ log(`sync error: ${err.message}`);
34
35
  }
35
36
  await sleep(INTERVAL);
36
37
  }
package/src/index.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { loadConfig, saveConfig, getConfigPath } from './config.js';
2
2
  import { detectInstalledTools, TOOLS } from './tools.js';
3
3
  import { existsSync } from 'node:fs';
4
+ import { smallHeader } from './output.js';
5
+
6
+ function printSmallHeader() {
7
+ console.log();
8
+ console.log(smallHeader());
9
+ console.log();
10
+ }
4
11
 
5
12
  async function showStatus() {
6
13
  const config = loadConfig();
@@ -111,11 +118,13 @@ export async function run(rawArgs) {
111
118
  break;
112
119
  }
113
120
  case 'sync': {
121
+ printSmallHeader();
114
122
  const { runSync } = await import('./sync.js');
115
123
  await runSync();
116
124
  break;
117
125
  }
118
126
  case 'reset': {
127
+ printSmallHeader();
119
128
  const { runReset } = await import('./reset.js');
120
129
  await runReset(args.slice(1));
121
130
  break;
@@ -124,15 +133,18 @@ export async function run(rawArgs) {
124
133
  case '--daemon': {
125
134
  const sub = args[1];
126
135
  if (sub && ['install', 'uninstall', 'status', 'stop', 'restart'].includes(sub)) {
136
+ printSmallHeader();
127
137
  const { manageDaemon } = await import('./daemon-service.js');
128
138
  await manageDaemon(sub);
129
139
  } else {
140
+ // Foreground daemon loop — no header, just start syncing
130
141
  const { runDaemon } = await import('./daemon.js');
131
142
  await runDaemon();
132
143
  }
133
144
  break;
134
145
  }
135
146
  case 'skill': {
147
+ printSmallHeader();
136
148
  const { runSkill } = await import('./skill.js');
137
149
  await runSkill(args.slice(1));
138
150
  break;
@@ -178,10 +190,12 @@ export async function run(rawArgs) {
178
190
  default: {
179
191
  const config = loadConfig();
180
192
  if (!config?.apiKey || apiKey) {
181
- // First run OR user passed --key for a one-shot setup
193
+ // First run OR user passed --key for a one-shot setup — init.js prints the big header
182
194
  const { runInit } = await import('./init.js');
183
195
  await runInit({ apiKey });
184
196
  } else {
197
+ // Already configured: small header + sync
198
+ printSmallHeader();
185
199
  const { runSync } = await import('./sync.js');
186
200
  await runSync();
187
201
  }
package/src/init.js CHANGED
@@ -5,6 +5,7 @@ import { loadConfig, saveConfig } from './config.js';
5
5
  import { ingest } from './api.js';
6
6
  import { runSync } from './sync.js';
7
7
  import { detectInstalledTools } from './tools.js';
8
+ import { bigHeader, success, failure, warn, arrow, link, dim, divider } from './output.js';
8
9
 
9
10
  function prompt(question) {
10
11
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -30,18 +31,19 @@ function isDaemonPlatform() {
30
31
  export async function runInit(options = {}) {
31
32
  const { apiKey: providedKey } = options;
32
33
 
33
- console.log('\n vibe-usage - Vibe Usage Tracker by VibeCaf\u00e9\n');
34
+ console.log(bigHeader());
34
35
 
35
36
  const existing = loadConfig();
36
37
  if (existing?.apiKey) {
37
38
  if (providedKey && existing.apiKey === providedKey) {
38
- console.log('Already configured with this key. Running sync...\n');
39
+ console.log(dim('已配置同一个 Key,直接同步数据。'));
40
+ console.log();
39
41
  await runSync();
40
42
  return;
41
43
  }
42
- const answer = await prompt('Config already exists. Overwrite? (y/N) ');
44
+ const answer = await prompt('检测到已有配置,是否覆盖? (y/N) ');
43
45
  if (answer.toLowerCase() !== 'y') {
44
- console.log('Cancelled.');
46
+ console.log(dim('已取消。'));
45
47
  return;
46
48
  }
47
49
  }
@@ -51,32 +53,32 @@ export async function runInit(options = {}) {
51
53
  let apiKey;
52
54
  if (providedKey) {
53
55
  if (!providedKey.startsWith('vbu_')) {
54
- console.error('Invalid API key must start with "vbu_".');
56
+ console.error(failure('API Key 无效,必须以 vbu_ 开头。'));
55
57
  process.exit(1);
56
58
  }
57
59
  apiKey = providedKey;
58
- console.log(`Using API key ${apiKey.slice(0, 8)}...`);
59
60
  } else {
60
- console.log(`Get your API key at: ${apiUrl}/usage\n`);
61
+ console.log(`${arrow('获取 API Key')} ${link(`${apiUrl}/usage`)}`);
62
+ console.log(dim(' 浏览器会自动打开,登录后复制 Key 粘贴到下方。'));
63
+ console.log();
61
64
  openBrowser(`${apiUrl}/usage`);
62
65
 
63
66
  while (true) {
64
- apiKey = await prompt('Paste your API key: ');
67
+ apiKey = await prompt('粘贴 API Key: ');
65
68
  if (apiKey.startsWith('vbu_')) break;
66
- console.log('Invalid key — must start with "vbu_". Try again.');
69
+ console.log(warn('必须以 vbu_ 开头,请重试。'));
67
70
  }
68
71
  }
69
72
 
70
- console.log(`\nVerifying key ${apiKey.slice(0, 8)}...`);
71
73
  try {
72
74
  await ingest(apiUrl, apiKey, []);
73
- console.log('Key verified.\n');
75
+ console.log(success(`验证通过 ${dim(apiKey.slice(0, 12) + '...')}`));
74
76
  } catch (err) {
75
77
  if (err.message === 'UNAUTHORIZED') {
76
- console.error('Invalid API key. Please check and try again.');
78
+ console.error(failure('API Key 无效,请检查后重试。'));
77
79
  process.exit(1);
78
80
  }
79
- console.log('Could not verify key (network error). Saving anyway.\n');
81
+ console.log(warn(`网络异常(${err.message}),跳过验证直接保存。`));
80
82
  }
81
83
 
82
84
  const config = {
@@ -88,30 +90,32 @@ export async function runInit(options = {}) {
88
90
 
89
91
  const tools = detectInstalledTools();
90
92
  if (tools.length > 0) {
91
- console.log(`Detected tools: ${tools.map(t => t.name).join(', ')}`);
93
+ console.log(success(`检测到 ${tools.length} 款工具: ${dim(tools.map(t => t.name).join(' · '))}`));
92
94
  } else {
93
- console.log('No AI coding tools detected. Install one and re-run init.');
95
+ console.log(warn('未检测到 AI 编码工具,安装后重新运行即可。'));
94
96
  }
95
97
 
96
- console.log('\nRunning initial sync...');
97
- await runSync();
98
+ console.log();
99
+ console.log(divider());
100
+ console.log();
98
101
 
99
- console.log(`\nSetup complete! View your dashboard at: ${apiUrl}/usage`);
102
+ await runSync();
100
103
 
101
104
  if (isDaemonPlatform()) {
102
105
  if (process.stdin.isTTY) {
103
- console.log('');
104
- const answer = await prompt('开启后台自动同步?(持续上报用量数据,推荐) [Y/n] ');
106
+ console.log();
107
+ const answer = await prompt(`开启后台自动同步?${dim('(推荐)')} [Y/n] `);
105
108
  const normalized = answer.toLowerCase();
106
109
  if (normalized === '' || normalized === 'y' || normalized === 'yes') {
107
110
  const { manageDaemon } = await import('./daemon-service.js');
108
111
  await manageDaemon('install');
109
112
  } else {
110
- console.log('\n可随时运行 `npx @vibe-cafe/vibe-usage daemon install` 开启后台同步。');
113
+ console.log();
114
+ console.log(dim('随时运行 `npx @vibe-cafe/vibe-usage daemon install` 开启后台同步。'));
111
115
  }
112
116
  } else {
113
- // Non-interactive shell (CI, pipe) — don't block on prompt
114
- console.log('\nTip: Run `npx @vibe-cafe/vibe-usage daemon install` to sync automatically in the background.');
117
+ console.log();
118
+ console.log(dim('提示: 运行 `npx @vibe-cafe/vibe-usage daemon install` 开启后台自动同步。'));
115
119
  }
116
120
  }
117
121
  }
package/src/output.js ADDED
@@ -0,0 +1,67 @@
1
+ // Terminal output helpers: colors, status markers, OSC 8 clickable links.
2
+ // Falls back to plain text when NO_COLOR is set or stdout is not a TTY.
3
+
4
+ const NO_COLOR = !!process.env.NO_COLOR || !process.stdout.isTTY;
5
+
6
+ const CODES = {
7
+ reset: '\x1b[0m',
8
+ bold: '\x1b[1m',
9
+ dim: '\x1b[2m',
10
+ underline: '\x1b[4m',
11
+ red: '\x1b[31m',
12
+ green: '\x1b[32m',
13
+ yellow: '\x1b[33m',
14
+ cyan: '\x1b[36m',
15
+ gray: '\x1b[90m',
16
+ };
17
+
18
+ function wrap(code, text) {
19
+ if (NO_COLOR) return String(text);
20
+ return `${code}${text}${CODES.reset}`;
21
+ }
22
+
23
+ export const bold = (t) => wrap(CODES.bold, t);
24
+ export const dim = (t) => wrap(CODES.dim, t);
25
+ export const red = (t) => wrap(CODES.red, t);
26
+ export const green = (t) => wrap(CODES.green, t);
27
+ export const yellow = (t) => wrap(CODES.yellow, t);
28
+ export const cyan = (t) => wrap(CODES.cyan, t);
29
+ export const gray = (t) => wrap(CODES.gray, t);
30
+
31
+ /** OSC 8 hyperlink — supported by modern terminals (iTerm2, Kitty, Warp, VSCode, macOS Terminal 14+). */
32
+ export function link(url, text = url) {
33
+ if (NO_COLOR) return url === text ? url : `${text} (${url})`;
34
+ return `\x1b]8;;${url}\x1b\\${CODES.cyan}${CODES.underline}${text}${CODES.reset}\x1b]8;;\x1b\\`;
35
+ }
36
+
37
+ export const success = (msg) => `${green('✓')} ${msg}`;
38
+ export const failure = (msg) => `${red('✗')} ${msg}`;
39
+ export const warn = (msg) => `${yellow('!')} ${msg}`;
40
+ export const arrow = (msg) => `${cyan('→')} ${msg}`;
41
+
42
+ export const divider = () => dim('─'.repeat(48));
43
+
44
+ /** Print a blank line. */
45
+ export const nl = () => console.log();
46
+
47
+ const LOGO_LINES = [
48
+ '██╗ ██╗██╗██████╗ ███████╗ ██╗ ██╗███████╗ █████╗ ██████╗ ███████╗',
49
+ '██║ ██║██║██╔══██╗██╔════╝ ██║ ██║██╔════╝██╔══██╗██╔════╝ ██╔════╝',
50
+ '██║ ██║██║██████╔╝█████╗ ██║ ██║███████╗███████║██║ ███╗█████╗ ',
51
+ '╚██╗ ██╔╝██║██╔══██╗██╔══╝ ██║ ██║╚════██║██╔══██║██║ ██║██╔══╝ ',
52
+ ' ╚████╔╝ ██║██████╔╝███████╗ ╚██████╔╝███████║██║ ██║╚██████╔╝███████╗',
53
+ ' ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝',
54
+ ];
55
+
56
+ /** Big ASCII logo — used once at the top of `init` / first-run. */
57
+ export function bigHeader() {
58
+ const logo = NO_COLOR
59
+ ? LOGO_LINES.join('\n')
60
+ : LOGO_LINES.map(l => `${CODES.cyan}${l}${CODES.reset}`).join('\n');
61
+ return `\n${logo}\n${dim(' Vibe Usage · by VibeCafé')}\n`;
62
+ }
63
+
64
+ /** Compact one-line header — used for `sync`, `daemon`, `reset`, `skill`. */
65
+ export function smallHeader() {
66
+ return `${bold('Vibe Usage')} ${dim('· by VibeCafé')}`;
67
+ }
@@ -1,95 +1,129 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { aggregateToBuckets, extractSessions } from './index.js';
6
6
 
7
7
  const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
8
- const DB_PATH = join(HERMES_HOME, 'state.db');
9
8
 
10
9
  /**
11
- * Parse Hermes Agent usage data from its SQLite database (~/.hermes/state.db).
10
+ * Parse Hermes Agent usage data from its SQLite databases.
11
+ *
12
+ * Hermes supports multiple profiles — the default profile lives at
13
+ * ~/.hermes/state.db, while named profiles live at ~/.hermes/profiles/<name>/state.db.
14
+ * Each profile is an independent HERMES_HOME with its own state.db, so we scan all of them.
12
15
  *
13
16
  * Token buckets come from the sessions table (cumulative per-session totals).
14
17
  * Session timing comes from the messages table (per-message role + timestamp).
15
18
  */
16
19
  export async function parse() {
17
- if (!existsSync(DB_PATH)) return { buckets: [], sessions: [] };
18
-
19
- let sessionRows;
20
- try {
21
- sessionRows = queryDb(`SELECT
22
- id,
23
- model,
24
- started_at as startedAt,
25
- input_tokens as inputTokens,
26
- output_tokens as outputTokens,
27
- cache_read_tokens as cacheReadTokens,
28
- reasoning_tokens as reasoningTokens
29
- FROM sessions
30
- WHERE input_tokens > 0 OR output_tokens > 0`);
31
- } catch (err) {
32
- if (err.message && err.message.includes('ENOENT')) {
33
- throw new Error('sqlite3 CLI not found. Install sqlite3 to sync Hermes data.');
34
- }
35
- throw err;
36
- }
20
+ const dbs = discoverDbPaths(HERMES_HOME);
21
+ if (dbs.length === 0) return { buckets: [], sessions: [] };
37
22
 
38
23
  const entries = [];
39
- for (const row of sessionRows) {
40
- // started_at is a Unix timestamp (float)
41
- const timestamp = new Date(row.startedAt * 1000);
42
- if (isNaN(timestamp.getTime())) continue;
43
-
44
- // Hermes stores input_tokens exclusive of cache (Anthropic-style semantics)
45
- entries.push({
46
- source: 'hermes',
47
- model: row.model || 'unknown',
48
- project: 'unknown',
49
- timestamp,
50
- inputTokens: row.inputTokens || 0,
51
- outputTokens: row.outputTokens || 0,
52
- cachedInputTokens: row.cacheReadTokens || 0,
53
- reasoningOutputTokens: row.reasoningTokens || 0,
54
- });
55
- }
24
+ const sessionEvents = [];
56
25
 
57
- // Session events from messages table for active time calculation
58
- let messageRows;
59
- try {
60
- messageRows = queryDb(`SELECT
61
- session_id as sessionId,
62
- role,
63
- timestamp
64
- FROM messages
65
- WHERE role IN ('user', 'assistant')
66
- ORDER BY timestamp`);
67
- } catch {
68
- // Messages query failed — return buckets only
69
- return { buckets: aggregateToBuckets(entries), sessions: [] };
70
- }
26
+ for (const { path: dbPath, profile } of dbs) {
27
+ let sessionRows;
28
+ try {
29
+ sessionRows = queryDb(dbPath, `SELECT
30
+ id,
31
+ model,
32
+ started_at as startedAt,
33
+ input_tokens as inputTokens,
34
+ output_tokens as outputTokens,
35
+ cache_read_tokens as cacheReadTokens,
36
+ reasoning_tokens as reasoningTokens
37
+ FROM sessions
38
+ WHERE input_tokens > 0 OR output_tokens > 0`);
39
+ } catch (err) {
40
+ if (err.message && err.message.includes('ENOENT')) {
41
+ throw new Error('sqlite3 CLI not found. Install sqlite3 to sync Hermes data.');
42
+ }
43
+ throw err;
44
+ }
71
45
 
72
- const sessionEvents = [];
73
- for (const row of messageRows) {
74
- const timestamp = new Date(row.timestamp * 1000);
75
- if (isNaN(timestamp.getTime())) continue;
76
-
77
- sessionEvents.push({
78
- sessionId: row.sessionId,
79
- source: 'hermes',
80
- project: 'unknown',
81
- timestamp,
82
- role: row.role === 'user' ? 'user' : 'assistant',
83
- });
46
+ for (const row of sessionRows) {
47
+ // started_at is a Unix timestamp (float)
48
+ const timestamp = new Date(row.startedAt * 1000);
49
+ if (isNaN(timestamp.getTime())) continue;
50
+
51
+ // Hermes stores input_tokens exclusive of cache (Anthropic-style semantics)
52
+ entries.push({
53
+ source: 'hermes',
54
+ model: row.model || 'unknown',
55
+ project: profile,
56
+ timestamp,
57
+ inputTokens: row.inputTokens || 0,
58
+ outputTokens: row.outputTokens || 0,
59
+ cachedInputTokens: row.cacheReadTokens || 0,
60
+ reasoningOutputTokens: row.reasoningTokens || 0,
61
+ });
62
+ }
63
+
64
+ let messageRows;
65
+ try {
66
+ messageRows = queryDb(dbPath, `SELECT
67
+ session_id as sessionId,
68
+ role,
69
+ timestamp
70
+ FROM messages
71
+ WHERE role IN ('user', 'assistant')
72
+ ORDER BY timestamp`);
73
+ } catch {
74
+ // Messages query failed for this profile — skip its session events
75
+ continue;
76
+ }
77
+
78
+ for (const row of messageRows) {
79
+ const timestamp = new Date(row.timestamp * 1000);
80
+ if (isNaN(timestamp.getTime())) continue;
81
+
82
+ sessionEvents.push({
83
+ sessionId: row.sessionId,
84
+ source: 'hermes',
85
+ project: profile,
86
+ timestamp,
87
+ role: row.role === 'user' ? 'user' : 'assistant',
88
+ });
89
+ }
84
90
  }
85
91
 
86
92
  return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
87
93
  }
88
94
 
89
- function queryDb(sql) {
95
+ function discoverDbPaths(home) {
96
+ const dbs = [];
97
+
98
+ const defaultDb = join(home, 'state.db');
99
+ if (existsSync(defaultDb)) dbs.push({ path: defaultDb, profile: 'default' });
100
+
101
+ const profilesDir = join(home, 'profiles');
102
+ if (existsSync(profilesDir)) {
103
+ let entries;
104
+ try {
105
+ entries = readdirSync(profilesDir, { withFileTypes: true });
106
+ } catch {
107
+ return dbs;
108
+ }
109
+ for (const entry of entries) {
110
+ if (!entry.isDirectory()) continue;
111
+ const profileDb = join(profilesDir, entry.name, 'state.db');
112
+ try {
113
+ if (statSync(profileDb).isFile()) dbs.push({ path: profileDb, profile: entry.name });
114
+ } catch {
115
+ // missing or unreadable — skip
116
+ }
117
+ }
118
+ }
119
+
120
+ return dbs;
121
+ }
122
+
123
+ function queryDb(dbPath, sql) {
90
124
  const output = execFileSync('sqlite3', [
91
125
  '-json',
92
- DB_PATH,
126
+ dbPath,
93
127
  sql,
94
128
  ], { encoding: 'utf-8', maxBuffer: 100 * 1024 * 1024, timeout: 30000 });
95
129
 
package/src/reset.js CHANGED
@@ -1,11 +1,9 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { homedir, hostname as getHostname } from 'node:os';
2
+ import { hostname as getHostname } from 'node:os';
5
3
  import { loadConfig, saveConfig } from './config.js';
6
4
  import { deleteAllData } from './api.js';
7
5
  import { runSync } from './sync.js';
8
-
6
+ import { success, failure, arrow, link, dim } from './output.js';
9
7
 
10
8
  function prompt(question) {
11
9
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -21,7 +19,7 @@ export async function runReset(args = []) {
21
19
  const hostOnly = args.includes('--local');
22
20
  const config = loadConfig();
23
21
  if (!config?.apiKey) {
24
- console.error('Not configured. Run `npx @vibe-cafe/vibe-usage init` first.');
22
+ console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
25
23
  process.exit(1);
26
24
  }
27
25
 
@@ -29,55 +27,53 @@ export async function runReset(args = []) {
29
27
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
30
28
 
31
29
  if (hostOnly) {
32
- const answer = await prompt(`This will delete usage data for this host (${currentHost}) and re-upload from local logs. Continue? (y/N) `);
30
+ const answer = await prompt(`将删除当前机器(${currentHost})的用量数据并从本地日志重新上传,继续? (y/N) `);
33
31
  if (answer.toLowerCase() !== 'y') {
34
- console.log('Cancelled.');
32
+ console.log(dim('已取消。'));
35
33
  return;
36
34
  }
37
35
 
38
- // 1. Delete remote data for this host
39
- console.log(`Deleting remote data for host: ${currentHost}...`);
36
+ console.log(dim(` 正在删除 ${currentHost} 的云端数据...`));
40
37
  try {
41
38
  const result = await deleteAllData(apiUrl, config.apiKey, { hostname: currentHost });
42
- console.log(`Deleted ${result.deleted} buckets, ${result.sessions ?? 0} sessions from server.`);
39
+ console.log(success(`已删除 ${result.deleted} buckets · ${result.sessions ?? 0} sessions`));
43
40
  } catch (err) {
44
41
  if (err.message === 'UNAUTHORIZED') {
45
- console.error('Invalid API key. Run `npx @vibe-cafe/vibe-usage init` to reconfigure.');
42
+ console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
46
43
  process.exit(1);
47
44
  }
48
- console.error(`Failed to delete remote data: ${err.message}`);
45
+ console.error(failure(`删除云端数据失败: ${err.message}`));
49
46
  process.exit(1);
50
47
  }
51
48
  } else {
52
- const answer = await prompt('This will delete ALL your usage data and re-upload from local logs. Continue? (y/N) ');
49
+ const answer = await prompt('将删除所有用量数据并从本地日志重新上传,继续? (y/N) ');
53
50
  if (answer.toLowerCase() !== 'y') {
54
- console.log('Cancelled.');
51
+ console.log(dim('已取消。'));
55
52
  return;
56
53
  }
57
54
 
58
- // 1. Delete all remote data
59
- console.log('Deleting all remote data...');
55
+ console.log(dim(' 正在删除所有云端数据...'));
60
56
  try {
61
57
  const result = await deleteAllData(apiUrl, config.apiKey);
62
- console.log(`Deleted ${result.deleted} buckets, ${result.sessions ?? 0} sessions from server.`);
58
+ console.log(success(`已删除 ${result.deleted} buckets · ${result.sessions ?? 0} sessions`));
63
59
  } catch (err) {
64
60
  if (err.message === 'UNAUTHORIZED') {
65
- console.error('Invalid API key. Run `npx @vibe-cafe/vibe-usage init` to reconfigure.');
61
+ console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
66
62
  process.exit(1);
67
63
  }
68
- console.error(`Failed to delete remote data: ${err.message}`);
64
+ console.error(failure(`删除云端数据失败: ${err.message}`));
69
65
  process.exit(1);
70
66
  }
71
67
  }
72
68
 
73
- // 2. Clear local state (legacy — no state files needed for current parsers)
69
+ // Clear local state (legacy — no state files needed for current parsers)
74
70
  config.lastSync = null;
75
71
  saveConfig(config);
76
- console.log('Cleared local sync state.');
77
72
 
78
- // 3. Re-upload everything
79
- console.log('\nRe-syncing all data...');
73
+ console.log();
74
+ console.log(dim(' 从本地日志重新同步...'));
80
75
  await runSync();
81
76
 
82
- console.log(`\nReset complete! View your dashboard at: ${apiUrl}/usage`);
77
+ console.log();
78
+ console.log(`${arrow('Dashboard')} ${link(`${apiUrl}/usage`)}`);
83
79
  }
package/src/skill.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { success, dim, green, red } from './output.js';
4
5
 
5
6
  const SKILL_TARGETS = [
6
7
  {
@@ -84,19 +85,18 @@ Other available commands:
84
85
  export async function runSkill(args = []) {
85
86
  const remove = args.includes('--remove');
86
87
 
87
- console.log('\nvibe-usage skill\n');
88
-
89
- console.log(' AI coding tools:');
88
+ console.log(' 检测到的工具:');
90
89
  for (const t of SKILL_TARGETS) {
91
90
  const found = existsSync(t.detectDir);
92
- console.log(` ${found ? '\u2713' : '\u2717'} ${t.name}`);
91
+ const mark = found ? green('') : red('');
92
+ console.log(` ${mark} ${t.name}`);
93
93
  }
94
94
  console.log();
95
95
 
96
96
  const detected = SKILL_TARGETS.filter(t => existsSync(t.detectDir));
97
97
 
98
98
  if (detected.length === 0) {
99
- console.log(' No supported tools detected. Nothing to do.\n');
99
+ console.log(dim(' 未检测到支持的工具,无需安装 Skill。'));
100
100
  return;
101
101
  }
102
102
 
@@ -107,14 +107,15 @@ export async function runSkill(args = []) {
107
107
  if (existsSync(skillFile)) {
108
108
  unlinkSync(skillFile);
109
109
  try { rmdirSync(t.skillDir); } catch {}
110
- console.log(` Removed: ${tildePath(skillFile)}`);
110
+ console.log(dim(` 已移除: ${tildePath(skillFile)}`));
111
111
  removed++;
112
112
  }
113
113
  }
114
114
  if (removed === 0) {
115
- console.log(' No skills installed to remove.\n');
115
+ console.log(dim(' 没有已安装的 Skill。'));
116
116
  } else {
117
- console.log(`\n Removed vibe-usage skill from ${removed} tool${removed > 1 ? 's' : ''}.\n`);
117
+ console.log();
118
+ console.log(success(`已从 ${removed} 个工具移除 Skill。`));
118
119
  }
119
120
  return;
120
121
  }
@@ -124,11 +125,11 @@ export async function runSkill(args = []) {
124
125
  const skillFile = join(t.skillDir, 'SKILL.md');
125
126
  mkdirSync(t.skillDir, { recursive: true });
126
127
  writeFileSync(skillFile, SKILL_CONTENT, 'utf-8');
127
- console.log(` Installed: ${tildePath(skillFile)}`);
128
+ console.log(dim(` 已安装: ${tildePath(skillFile)}`));
128
129
  installed++;
129
130
  }
130
131
 
131
- console.log(`\n Installed vibe-usage skill for ${installed} tool${installed > 1 ? 's' : ''}.\n`);
132
- console.log(' Your AI coding assistant now knows how to sync usage data.');
133
- console.log(' Try asking: "sync my vibe usage" or "how much have I spent?"\n');
132
+ console.log();
133
+ console.log(success(`已为 ${installed} 个工具安装 Skill。`));
134
+ console.log(dim(' AI 助手现在可以自主帮你同步用量数据。'));
134
135
  }
package/src/sync.js CHANGED
@@ -2,6 +2,7 @@ import { hostname as osHostname } from 'node:os';
2
2
  import { loadConfig, saveConfig } from './config.js';
3
3
  import { ingest, fetchSettings } from './api.js';
4
4
  import { parsers } from './parsers/index.js';
5
+ import { success, failure, arrow, link, dim } from './output.js';
5
6
 
6
7
  const BATCH_SIZE = 100;
7
8
  const SESSION_BATCH_SIZE = 500;
@@ -15,7 +16,7 @@ function formatBytes(bytes) {
15
16
  export async function runSync({ throws = false, quiet = false } = {}) {
16
17
  const config = loadConfig();
17
18
  if (!config?.apiKey) {
18
- console.error('Not configured. Run `npx @vibe-cafe/vibe-usage init` first.');
19
+ console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
19
20
  if (throws) throw new Error('NOT_CONFIGURED');
20
21
  process.exit(1);
21
22
  }
@@ -41,12 +42,13 @@ export async function runSync({ throws = false, quiet = false } = {}) {
41
42
  parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
42
43
  }
43
44
  } catch (err) {
44
- process.stderr.write(`warn: ${source} parser failed: ${err.message}\n`);
45
+ // Parser errors are non-fatal — pass-through in dim gray (no translation).
46
+ process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
45
47
  }
46
48
  }
47
49
 
48
50
  if (allBuckets.length === 0 && allSessions.length === 0) {
49
- if (!quiet) console.log('No new usage data found.');
51
+ if (!quiet) console.log(dim('暂无新数据。'));
50
52
  return 0;
51
53
  }
52
54
 
@@ -55,7 +57,7 @@ export async function runSync({ throws = false, quiet = false } = {}) {
55
57
  const parts = [];
56
58
  if (p.buckets > 0) parts.push(`${p.buckets} buckets`);
57
59
  if (p.sessions > 0) parts.push(`${p.sessions} sessions`);
58
- console.log(` ${p.source}: ${parts.join(', ')}`);
60
+ console.log(` ${dim(p.source.padEnd(14))}${parts.join(' · ')}`);
59
61
  }
60
62
  }
61
63
 
@@ -65,29 +67,25 @@ export async function runSync({ throws = false, quiet = false } = {}) {
65
67
  config.hostname = host;
66
68
  saveConfig(config);
67
69
  }
68
- for (const b of allBuckets) {
69
- b.hostname = host;
70
- }
71
- for (const s of allSessions) {
72
- s.hostname = host;
73
- }
70
+ for (const b of allBuckets) b.hostname = host;
71
+ for (const s of allSessions) s.hostname = host;
74
72
 
75
73
  // Privacy: check if user allows project name upload
76
74
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
77
75
  const settings = await fetchSettings(apiUrl, config.apiKey);
78
76
  const uploadProject = settings?.uploadProject === true;
79
77
 
80
- if (uploadProject) {
81
- console.log('📂 项目名: 上传 (可在 vibecafe.ai/usage 设置中关闭)');
82
- } else {
83
- console.log('🔒 项目名: 已隐藏');
84
- for (const b of allBuckets) {
85
- b.project = 'unknown';
86
- }
87
- for (const s of allSessions) {
88
- s.project = 'unknown';
78
+ if (!quiet) {
79
+ if (uploadProject) {
80
+ console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
81
+ } else {
82
+ console.log(dim(' 项目名: 已隐藏'));
89
83
  }
90
84
  }
85
+ if (!uploadProject) {
86
+ for (const b of allBuckets) b.project = 'unknown';
87
+ for (const s of allSessions) s.project = 'unknown';
88
+ }
91
89
 
92
90
  let totalIngested = 0;
93
91
  let totalSessionsSynced = 0;
@@ -95,22 +93,17 @@ export async function runSync({ throws = false, quiet = false } = {}) {
95
93
  const sessionBatches = Math.ceil(allSessions.length / SESSION_BATCH_SIZE);
96
94
  const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
97
95
 
98
- const parts = [];
99
- if (allBuckets.length > 0) parts.push(`${allBuckets.length} buckets`);
100
- if (allSessions.length > 0) parts.push(`${allSessions.length} sessions`);
101
- console.log(`Uploading ${parts.join(' + ')} (${totalBatches} batch${totalBatches > 1 ? 'es' : ''})...`);
102
-
103
96
  try {
104
97
  for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
105
98
  const batch = allBuckets.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
106
99
  const batchSessions = allSessions.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
107
100
  const batchNum = batchIdx + 1;
108
- const prefix = totalBatches > 1 ? ` [${batchNum}/${totalBatches}] ` : ' ';
101
+ const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
109
102
 
110
103
  const result = await ingest(apiUrl, config.apiKey, batch, {
111
104
  onProgress(sent, total) {
112
105
  const pct = Math.round((sent / total) * 100);
113
- process.stdout.write(`\r${prefix}${formatBytes(sent)}/${formatBytes(total)} (${pct}%)\x1b[K`);
106
+ process.stdout.write(`\r${prefix}${dim(`${formatBytes(sent)}/${formatBytes(total)} (${pct}%)`)}\x1b[K`);
114
107
  },
115
108
  }, batchSessions.length > 0 ? batchSessions : undefined);
116
109
  totalIngested += result.ingested ?? batch.length;
@@ -118,11 +111,11 @@ export async function runSync({ throws = false, quiet = false } = {}) {
118
111
  }
119
112
 
120
113
  if (totalBatches > 1 || allBuckets.length > 0) {
121
- process.stdout.write('\n');
114
+ process.stdout.write('\r\x1b[K');
122
115
  }
123
116
  const syncParts = [`${totalIngested} buckets`];
124
117
  if (totalSessionsSynced > 0) syncParts.push(`${totalSessionsSynced} sessions`);
125
- console.log(`Synced ${syncParts.join(' + ')}.`);
118
+ console.log(success(`已同步 ${syncParts.join(' · ')}`));
126
119
 
127
120
  if (!quiet && totalSessionsSynced > 0) {
128
121
  const totalActive = allSessions.reduce((s, x) => s + x.activeSeconds, 0);
@@ -134,25 +127,28 @@ export async function runSync({ throws = false, quiet = false } = {}) {
134
127
  const m = Math.floor((secs % 3600) / 60);
135
128
  return h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
136
129
  };
137
- console.log(` active: ${fmtTime(totalActive)} / total: ${fmtTime(totalDuration)}, ${totalMsgs} messages`);
130
+ console.log(dim(` 活跃 ${fmtTime(totalActive)} / 总时长 ${fmtTime(totalDuration)} · ${totalMsgs} 条消息`));
138
131
  }
139
132
 
140
- if (!quiet) console.log(`\nView your dashboard at: ${apiUrl}/usage`);
133
+ if (!quiet) {
134
+ console.log();
135
+ console.log(`${arrow('前往 Dashboard 查看详情')} ${link(`${apiUrl}/usage`)}`);
136
+ }
141
137
 
142
138
  return totalIngested;
143
139
  } catch (err) {
144
140
  if (err.message === 'UNAUTHORIZED') {
145
- console.error('Invalid API key. Run `npx @vibe-cafe/vibe-usage init` to reconfigure.');
141
+ console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
146
142
  if (throws) throw err;
147
143
  process.exit(1);
148
144
  }
149
- // Report partial success
150
145
  if (totalIngested > 0) {
151
- console.error(`Sync partially completed (${totalIngested} buckets uploaded). ${err.message}`);
146
+ console.error(failure(`部分完成(已上传 ${totalIngested} buckets): ${err.message}`));
152
147
  } else {
153
- console.error(`Sync failed: ${err.message}`);
148
+ console.error(failure(`同步失败: ${err.message}`));
154
149
  }
155
150
  if (throws) throw err;
156
151
  process.exit(1);
157
152
  }
158
153
  }
154
+