omni-ai-cli 1.4.0 → 1.6.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,165 @@
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
+ READERS = {'omni': _read_omni, 'claude': _read_claude, 'codex': _read_codex}
139
+
140
+
141
+ def conversations(sources=None):
142
+ """All conversations (with messages), newest first."""
143
+ picked = sources or list(READERS)
144
+ items = []
145
+ for name in picked:
146
+ reader = READERS.get(name)
147
+ if not reader:
148
+ continue
149
+ try:
150
+ items.extend(reader())
151
+ except Exception: # a broken tool directory must not sink the rest
152
+ continue
153
+ items.sort(key=lambda c: c.get('updated') or 0, reverse=True)
154
+ return items
155
+
156
+
157
+ def sessions(sources=None):
158
+ """Metadata only (no messages), newest first — for a fast list."""
159
+ return [{k: v for k, v in c.items() if k != 'messages'} | {'count': len(c['messages'])}
160
+ for c in conversations(sources)]
161
+
162
+
163
+ def conversation(conv_id):
164
+ """One conversation in full, by its id, or None."""
165
+ return next((c for c in conversations() if c['id'] == conv_id), None)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-ai-cli",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",