omni-ai-cli 1.6.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.
Files changed (2) hide show
  1. package/omni_ai/history.py +108 -15
  2. package/package.json +1 -1
@@ -135,31 +135,124 @@ def _read_codex():
135
135
  'cwd': cwd, 'messages': messages}
136
136
 
137
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
+
138
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)}
139
222
 
140
223
 
141
- def conversations(sources=None):
142
- """All conversations (with messages), newest first."""
143
- picked = sources or list(READERS)
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
144
230
  items = []
145
231
  for name in picked:
146
- reader = READERS.get(name)
147
- if not reader:
148
- continue
149
232
  try:
150
- items.extend(reader())
151
- except Exception: # a broken tool directory must not sink the rest
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
152
239
  continue
153
240
  items.sort(key=lambda c: c.get('updated') or 0, reverse=True)
154
241
  return items
155
242
 
156
243
 
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
244
  def conversation(conv_id):
164
245
  """One conversation in full, by its id, or None."""
165
- return next((c for c in conversations() if c['id'] == conv_id), 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.6.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",