omni-ai-cli 1.5.0 → 1.7.0

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/omni_ai/cli.py CHANGED
@@ -690,11 +690,60 @@ def ai_version():
690
690
  return '?'
691
691
 
692
692
 
693
+ SOURCE_LABEL = {'omni': f'{CYAN}omni{NC}', 'claude': f'{YELLOW}claude{NC}', 'codex': f'{GREEN}codex{NC}',
694
+ 'cursor': 'cursor', 'agy': 'antigravity'}
695
+
696
+
697
+ def history_command(args):
698
+ """`ai history [list] [--source X] [--json]` lists past conversations across tools;
699
+ `ai history <id> [--json]` prints one. Read-only."""
700
+ from . import history as history_mod
701
+ as_json = '--json' in args
702
+ rest = [a for a in args if a != '--json']
703
+ source = None
704
+ if '--source' in rest:
705
+ idx = rest.index('--source')
706
+ source = rest[idx + 1] if idx + 1 < len(rest) else None
707
+ rest = rest[:idx] + rest[idx + 2:]
708
+ target = next((a for a in rest if a != 'list'), None)
709
+
710
+ if target:
711
+ conv = history_mod.conversation(target)
712
+ if not conv:
713
+ if as_json:
714
+ say('null')
715
+ else:
716
+ say(f'{RED}✗ Conversa não encontrada: {target}{NC}')
717
+ return 1
718
+ if as_json:
719
+ say(json.dumps(conv, ensure_ascii=False))
720
+ return 0
721
+ say(f"{BOLD}{conv['title']}{NC} {DIM}· {SOURCE_LABEL.get(conv['source'], conv['source'])} · {conv.get('cwd', '')}{NC}\n")
722
+ for m in conv['messages']:
723
+ who = f'{CYAN}você{NC}' if m['role'] == 'user' else f'{GREEN}●{NC}'
724
+ say(f"{who} {m['content']}\n")
725
+ return 0
726
+
727
+ items = history_mod.sessions([source] if source else None)
728
+ if as_json:
729
+ say(json.dumps(items, ensure_ascii=False))
730
+ return 0
731
+ if not items:
732
+ say(f'{DIM}Nenhuma conversa encontrada.{NC}')
733
+ return 0
734
+ import time as _time
735
+ for it in items:
736
+ when = _time.strftime('%d/%m %H:%M', _time.localtime(it.get('updated') or 0))
737
+ say(f"{SOURCE_LABEL.get(it['source'], it['source'])} {DIM}{when} · {it['count']}msg{NC} {it['title']}")
738
+ say(f" {DIM}{it['id']}{NC}")
739
+ return 0
740
+
741
+
693
742
  def completion_words():
694
743
  """Every top-level word `ai` accepts, for shell completion."""
695
744
  flags = ['--model', '--account', '--key', '--keys', '--sync', '--usage', '--limits', '--setup',
696
745
  '--install', '--status', '--doctor', '--reset', '--log', '--classify', '--update',
697
- '--mode', '--chat', '--resume', '--sessions', '--new', '--help', '--version', '--completions']
746
+ '--mode', '--chat', '--resume', '--sessions', '--new', '--help', '--version', '--completions', 'history']
698
747
  return flags + ['--' + name for name in TOOLS] + ['doctor']
699
748
 
700
749
 
@@ -1020,6 +1069,7 @@ USAGE = f'''{BOLD}Uso:{NC}
1020
1069
  ai --update Atualizar para a versão mais nova
1021
1070
  ai --version Versão do Omni AI CLI
1022
1071
  ai --completions zsh|bash Script de autocomplete do shell
1072
+ ai history [list|<id>] Conversas de todas as ferramentas (--source, --json)
1023
1073
  '''
1024
1074
 
1025
1075
 
@@ -1047,6 +1097,8 @@ def main(argv):
1047
1097
  return 0
1048
1098
  if head in ('--update', '--upgrade'):
1049
1099
  return update()
1100
+ if head in ('history', '--history'):
1101
+ return history_command(argv[1:])
1050
1102
  if head == '--sync':
1051
1103
  from . import sync
1052
1104
  return sync.main(argv[1:])
@@ -0,0 +1,258 @@
1
+ """Unified, read-only view of past conversations across tools.
2
+
3
+ Each tool stores its chats in its own place and format; this module discovers them and normalizes
4
+ them to one shape so the CLI (`ai history`) and the editor extension can show them together:
5
+
6
+ {'id': 'omni:1b0b...', 'source': 'omni', 'title': str, 'updated': float (epoch),
7
+ 'cwd': str, 'count': int, 'messages': [{'role': 'user'|'assistant', 'content': str}]}
8
+
9
+ Readers are defensive: a malformed file is skipped, never fatal. `sessions()` returns metadata only
10
+ (no messages) for a fast list; `conversation(id)` loads one in full.
11
+ """
12
+ import glob
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+
17
+
18
+ def _first_line(text, limit=80):
19
+ line = ' '.join(str(text).split())
20
+ return line[:limit - 1] + '…' if len(line) > limit else line
21
+
22
+
23
+ def _dir(env, default):
24
+ return Path(os.environ.get(env, default)).expanduser()
25
+
26
+
27
+ # ─── Omni ────────────────────────────────────────────────────────
28
+
29
+ def _omni_dir():
30
+ env = os.environ.get('SMART_AI_SESSIONS')
31
+ return Path(env).expanduser() if env else _dir('SMART_AI_DATA', '~/.local/share/smart-ai') / 'sessions'
32
+
33
+
34
+ def _omni_messages(data):
35
+ out = []
36
+ for m in data.get('messages', []):
37
+ role = 'assistant' if m.get('role') == 'assistant' else 'user'
38
+ content = m.get('content')
39
+ if isinstance(content, str) and content.strip():
40
+ out.append({'role': role, 'content': content})
41
+ return out
42
+
43
+
44
+ def _read_omni():
45
+ for path in glob.glob(str(_omni_dir() / '*.json')):
46
+ try:
47
+ data = json.loads(Path(path).read_text())
48
+ except (OSError, ValueError):
49
+ continue
50
+ messages = _omni_messages(data)
51
+ if not messages:
52
+ continue
53
+ yield {'id': 'omni:' + data.get('id', Path(path).stem), 'source': 'omni',
54
+ 'title': _first_line(messages[0]['content']), 'updated': data.get('updated') or os.path.getmtime(path),
55
+ 'cwd': data.get('cwd', ''), 'messages': messages}
56
+
57
+
58
+ # ─── Claude Code ─────────────────────────────────────────────────
59
+
60
+ def _claude_text(message):
61
+ content = message.get('content')
62
+ if isinstance(content, str):
63
+ return content
64
+ if isinstance(content, list):
65
+ return '\n'.join(b.get('text', '') for b in content
66
+ if isinstance(b, dict) and b.get('type') == 'text')
67
+ return ''
68
+
69
+
70
+ def _read_claude():
71
+ root = _dir('CLAUDE_CONFIG_DIR', '~/.claude') / 'projects'
72
+ for path in glob.glob(str(root / '*' / '*.jsonl')):
73
+ messages, cwd = [], ''
74
+ try:
75
+ for raw in Path(path).read_text(errors='replace').splitlines():
76
+ try:
77
+ obj = json.loads(raw)
78
+ except ValueError:
79
+ continue
80
+ cwd = obj.get('cwd', cwd)
81
+ if obj.get('type') in ('user', 'assistant') and isinstance(obj.get('message'), dict):
82
+ text = _claude_text(obj['message'])
83
+ if text.strip():
84
+ messages.append({'role': obj['type'], 'content': text})
85
+ except OSError:
86
+ continue
87
+ if not messages:
88
+ continue
89
+ yield {'id': 'claude:' + Path(path).stem, 'source': 'claude',
90
+ 'title': _first_line(messages[0]['content']), 'updated': os.path.getmtime(path),
91
+ 'cwd': cwd, 'messages': messages}
92
+
93
+
94
+ # ─── Codex ───────────────────────────────────────────────────────
95
+
96
+ def _codex_text(payload):
97
+ content = payload.get('content')
98
+ if isinstance(content, str):
99
+ return content
100
+ if isinstance(content, list):
101
+ parts = []
102
+ for b in content:
103
+ if isinstance(b, dict):
104
+ parts.append(b.get('text') or b.get('content') or '')
105
+ elif isinstance(b, str):
106
+ parts.append(b)
107
+ return '\n'.join(p for p in parts if p)
108
+ return payload.get('text', '') if isinstance(payload.get('text'), str) else ''
109
+
110
+
111
+ def _read_codex():
112
+ root = _dir('CODEX_HOME', '~/.codex') / 'sessions'
113
+ for path in glob.glob(str(root / '**' / '*.jsonl'), recursive=True):
114
+ messages, cwd = [], ''
115
+ try:
116
+ for raw in Path(path).read_text(errors='replace').splitlines():
117
+ try:
118
+ obj = json.loads(raw)
119
+ except ValueError:
120
+ continue
121
+ payload = obj.get('payload', obj)
122
+ if obj.get('type') == 'session_meta' or payload.get('type') == 'session_meta':
123
+ cwd = payload.get('cwd', cwd)
124
+ role = payload.get('role')
125
+ if role in ('user', 'assistant'):
126
+ text = _codex_text(payload)
127
+ if text.strip():
128
+ messages.append({'role': role, 'content': text})
129
+ except OSError:
130
+ continue
131
+ if not messages:
132
+ continue
133
+ yield {'id': 'codex:' + Path(path).stem, 'source': 'codex',
134
+ 'title': _first_line(messages[0]['content']), 'updated': os.path.getmtime(path),
135
+ 'cwd': cwd, 'messages': messages}
136
+
137
+
138
+ # ─── Cursor ──────────────────────────────────────────────────────
139
+ # Cursor keeps chats in a SQLite "state" DB: composerData:<id> holds a conversation's metadata and the
140
+ # ordered bubble headers; bubbleId:<composerId>:<bubbleId> holds each message (type 1=user, 2=assistant).
141
+ # Listing reads only composerData (fast); the bubbles are fetched only when one conversation is opened.
142
+
143
+ def _cursor_db():
144
+ env = os.environ.get('CURSOR_STATE_DB')
145
+ if env:
146
+ return Path(env).expanduser()
147
+ for candidate in ('~/Library/Application Support/Cursor/User/globalStorage/state.vscdb',
148
+ '~/.config/Cursor/User/globalStorage/state.vscdb',
149
+ '~/AppData/Roaming/Cursor/User/globalStorage/state.vscdb'):
150
+ path = Path(candidate).expanduser()
151
+ if path.is_file():
152
+ return path
153
+ return None
154
+
155
+
156
+ def _cursor_connect():
157
+ import sqlite3
158
+ db = _cursor_db()
159
+ if not db or not db.is_file():
160
+ return None
161
+ try:
162
+ return sqlite3.connect(f'file:{db}?mode=ro', uri=True)
163
+ except Exception:
164
+ return None
165
+
166
+
167
+ def _cursor_sessions():
168
+ con = _cursor_connect()
169
+ if not con:
170
+ return
171
+ try:
172
+ for (value,) in con.execute("SELECT value FROM cursorDiskKV WHERE key LIKE 'composerData:%'"):
173
+ try:
174
+ d = json.loads(value)
175
+ except ValueError:
176
+ continue
177
+ heads = d.get('fullConversationHeadersOnly') or []
178
+ name = d.get('name') or (d.get('text') or '').strip()
179
+ if not name or not heads:
180
+ continue
181
+ yield {'id': 'cursor:' + d.get('composerId', ''), 'source': 'cursor',
182
+ 'title': _first_line(name), 'updated': (d.get('lastUpdatedAt') or d.get('createdAt') or 0) / 1000,
183
+ 'cwd': '', 'count': len(heads)}
184
+ finally:
185
+ con.close()
186
+
187
+
188
+ def _cursor_load(conv_id):
189
+ cid = conv_id.split(':', 1)[1]
190
+ con = _cursor_connect()
191
+ if not con:
192
+ return None
193
+ try:
194
+ row = con.execute("SELECT value FROM cursorDiskKV WHERE key=?", (f'composerData:{cid}',)).fetchone()
195
+ if not row:
196
+ return None
197
+ d = json.loads(row[0])
198
+ messages = []
199
+ for head in d.get('fullConversationHeadersOnly') or []:
200
+ bid = head.get('bubbleId')
201
+ br = con.execute("SELECT value FROM cursorDiskKV WHERE key=?", (f'bubbleId:{cid}:{bid}',)).fetchone()
202
+ if not br:
203
+ continue
204
+ try:
205
+ b = json.loads(br[0])
206
+ except ValueError:
207
+ continue
208
+ text = b.get('text') or ''
209
+ if text.strip():
210
+ messages.append({'role': 'assistant' if b.get('type') == 2 else 'user', 'content': text})
211
+ if not messages:
212
+ return None
213
+ return {'id': conv_id, 'source': 'cursor', 'title': _first_line(d.get('name') or messages[0]['content']),
214
+ 'updated': (d.get('lastUpdatedAt') or d.get('createdAt') or 0) / 1000, 'cwd': '', 'messages': messages}
215
+ finally:
216
+ con.close()
217
+
218
+
219
+ READERS = {'omni': _read_omni, 'claude': _read_claude, 'codex': _read_codex}
220
+ # Sources with a fast metadata list separate from the heavy per-conversation load.
221
+ LAZY = {'cursor': (_cursor_sessions, _cursor_load)}
222
+
223
+
224
+ ALL_SOURCES = list(READERS) + list(LAZY)
225
+
226
+
227
+ def sessions(sources=None):
228
+ """Metadata only (no messages), newest first — for a fast list across all sources."""
229
+ picked = sources or ALL_SOURCES
230
+ items = []
231
+ for name in picked:
232
+ try:
233
+ if name in READERS:
234
+ items += [{k: v for k, v in c.items() if k != 'messages'} | {'count': len(c['messages'])}
235
+ for c in READERS[name]()]
236
+ elif name in LAZY:
237
+ items += list(LAZY[name][0]())
238
+ except Exception: # a broken tool store must not sink the rest
239
+ continue
240
+ items.sort(key=lambda c: c.get('updated') or 0, reverse=True)
241
+ return items
242
+
243
+
244
+ def conversation(conv_id):
245
+ """One conversation in full, by its id, or None."""
246
+ source = conv_id.split(':', 1)[0]
247
+ if source in LAZY:
248
+ try:
249
+ return LAZY[source][1](conv_id)
250
+ except Exception:
251
+ return None
252
+ reader = READERS.get(source)
253
+ if not reader:
254
+ return None
255
+ try:
256
+ return next((c for c in reader() if c['id'] == conv_id), None)
257
+ except Exception:
258
+ return None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-ai-cli",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "One chat for Claude Code, Codex, Antigravity and OpenRouter, with automatic routing, account fallback, API keys, permission modes and MCP/skill sync.",
5
5
  "bin": {
6
6
  "ai": "bin/ai.js",