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 +53 -1
- package/omni_ai/history.py +258 -0
- package/package.json +1 -1
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.
|
|
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",
|