entelligence-cli 0.1.97__tar.gz → 0.1.99__tar.gz

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 (85) hide show
  1. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/PKG-INFO +1 -1
  2. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/api_client.py +14 -0
  3. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/insights.py +145 -23
  4. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/discovery.py +224 -4
  5. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/models.py +10 -1
  6. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/runner.py +5 -3
  7. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli.egg-info/PKG-INFO +1 -1
  8. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli.egg-info/SOURCES.txt +3 -0
  9. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/pyproject.toml +1 -1
  10. entelligence_cli-0.1.99/tests/test_insights_kiro_prompts.py +106 -0
  11. entelligence_cli-0.1.99/tests/test_insights_structured_payload.py +148 -0
  12. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_upload_home_backfill.py +10 -2
  13. entelligence_cli-0.1.99/tests/test_upload_kiro.py +375 -0
  14. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/README.md +0 -0
  15. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/__init__.py +0 -0
  16. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/ascii-art.txt +0 -0
  17. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/browser_login.py +0 -0
  18. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/cli.py +0 -0
  19. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/__init__.py +0 -0
  20. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/auth.py +0 -0
  21. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/budget.py +0 -0
  22. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/config.py +0 -0
  23. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/incident.py +0 -0
  24. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/review.py +0 -0
  25. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/router.py +0 -0
  26. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/security.py +0 -0
  27. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/commands/update.py +0 -0
  28. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/config.py +0 -0
  29. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/exceptions.py +0 -0
  30. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/git_operations.py +0 -0
  31. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/__init__.py +0 -0
  32. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/bedrock.py +0 -0
  33. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/catalog.py +0 -0
  34. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/claude_settings.py +0 -0
  35. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/codex_auth.py +0 -0
  36. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/codex_settings.py +0 -0
  37. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/compress.py +0 -0
  38. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/daemon.py +0 -0
  39. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/engine.py +0 -0
  40. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/gateway.py +0 -0
  41. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/gemini_translate.py +0 -0
  42. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/openai_translate.py +0 -0
  43. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/proxy.py +0 -0
  44. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/router/state.py +0 -0
  45. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/scheduling.py +0 -0
  46. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/textual_ui.py +0 -0
  47. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/ui/__init__.py +0 -0
  48. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/ui/interactive.py +0 -0
  49. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/ui/loaders.py +0 -0
  50. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/ui/output.py +0 -0
  51. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/ui/styles.py +0 -0
  52. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/update_checker.py +0 -0
  53. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/__init__.py +0 -0
  54. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/conductor.py +0 -0
  55. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/config.py +0 -0
  56. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/cursor_usage.py +0 -0
  57. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/manifest.py +0 -0
  58. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/pii.py +0 -0
  59. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/secrets.py +0 -0
  60. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/upload/stores.py +0 -0
  61. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli/wrapped_game.py +0 -0
  62. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli.egg-info/dependency_links.txt +0 -0
  63. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli.egg-info/entry_points.txt +0 -0
  64. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli.egg-info/requires.txt +0 -0
  65. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/entelligence_cli.egg-info/top_level.txt +0 -0
  66. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/setup.cfg +0 -0
  67. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_attach_fix_smoke.py +0 -0
  68. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_attach_ticket_smoke.py +0 -0
  69. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_budget_smoke.py +0 -0
  70. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_codex_auth.py +0 -0
  71. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_codex_settings.py +0 -0
  72. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_conductor.py +0 -0
  73. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_cursor_usage.py +0 -0
  74. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_discovery_head_unicode.py +0 -0
  75. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_discovery_opencode.py +0 -0
  76. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_incident_smoke.py +0 -0
  77. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_insights_me_smoke.py +0 -0
  78. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_pii.py +0 -0
  79. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_presigned_skip_index.py +0 -0
  80. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_router_commands.py +0 -0
  81. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_security_commands.py +0 -0
  82. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_update_command.py +0 -0
  83. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_upload_cursor_store.py +0 -0
  84. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_upload_repo_filter.py +0 -0
  85. {entelligence_cli-0.1.97 → entelligence_cli-0.1.99}/tests/test_upload_repo_roots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entelligence-cli
3
- Version: 0.1.97
3
+ Version: 0.1.99
4
4
  Summary: AI-powered code review assistant for your terminal - Get instant, intelligent feedback on your code changes
5
5
  Author-email: EntelligenceAI <info@entelligence.ai>
6
6
  Project-URL: Homepage, https://entelligence.ai
@@ -759,6 +759,20 @@ class APIClient:
759
759
  """
760
760
  return self._eku_post("/agentInsightsIngest/", payload, org_uuid=org_uuid)
761
761
 
762
+ def ingest_agent_insights_structured(self, org_uuid: str, payload: dict) -> dict:
763
+ """`POST /agent-insights/ingest-structured/` — push locally-computed
764
+ session+task data to the coding-agent graph (ENT-3631/ENT-3634).
765
+
766
+ `payload` carries {orgUUID, clientVersion?, extractionVersion,
767
+ sessions:[...]}; org/user identity is derived server-side from the
768
+ token, same as `ingest_agent_insights`. Returns the parsed response
769
+ dict ({sessions_stored, sessions_skipped, tasks_written,
770
+ tasks_deferred, ...}), or `{"Error": "<message>"}` on failure.
771
+ """
772
+ return self._eku_post(
773
+ "/agent-insights/ingest-structured/", payload, org_uuid=org_uuid, timeout=120
774
+ )
775
+
762
776
  def fetch_organization_repos(self, org_uuid: str) -> dict:
763
777
  """`POST /fetchOrganizationRepos/` — list every repo connected to the org.
764
778
 
@@ -135,6 +135,73 @@ _PUSH_COLUMNS = (
135
135
  "effort_level",
136
136
  )
137
137
 
138
+ # ENT-3634 — the structured-ingest payload contract (ENT-3633). Bump if the
139
+ # shape changes in a way older backends can't accept; must stay >= the
140
+ # backend's own MIN_SUPPORTED_EXTRACTION_VERSION (agent_insights/controller.py).
141
+ _STRUCTURED_EXTRACTION_VERSION = 1
142
+
143
+ # cinsights' local column names -> the structured-ingest endpoint's field
144
+ # names. Built by reading cinsights' actual `db/models.py`, not assumed —
145
+ # several names differ (`agent_type` vs `agent`, `prompt_tokens` vs
146
+ # `input_tokens`, the three *_waste columns vs *_waste_tokens, etc.). Fields
147
+ # genuinely absent from cinsights today (repo_name, branch, is_automated,
148
+ # per-task category/files_touched/counters) are simply not in either map and
149
+ # stay unset on the wire — the backend already defaults them (ENT-3739 tracks
150
+ # adding the missing per-task computation to cinsights).
151
+ _SESSION_FIELD_MAP = {
152
+ "agent_type": "agent",
153
+ "model": "model",
154
+ "start_time": "start_time",
155
+ "end_time": "end_time",
156
+ "prompt_tokens": "input_tokens",
157
+ "completion_tokens": "output_tokens",
158
+ "turn_count": "turn_count",
159
+ "span_count": "span_count",
160
+ "interrupted_turn_waste": "interrupt_waste_tokens",
161
+ "repeated_edit_waste": "repeat_waste_tokens",
162
+ "failed_retry_waste": "retry_waste_tokens",
163
+ "floor_drift_score": "floor_drift",
164
+ "agent_version": "agent_version",
165
+ "effort_level": "effort_level",
166
+ "adaptive_thinking_disabled": "adaptive_thinking_disabled",
167
+ }
168
+ _TASK_FIELD_MAP = {
169
+ "task_number": "task_number",
170
+ "name": "name",
171
+ "description": "description",
172
+ "start_turn": "start_turn",
173
+ "end_turn": "end_turn",
174
+ "turn_count": "turn_count",
175
+ "prompt_tokens_total": "prompt_tokens_total",
176
+ "completion_tokens_total": "completion_tokens_total",
177
+ "context_at_start": "context_at_start",
178
+ "estimated_waste_tokens": "estimated_waste_tokens",
179
+ }
180
+
181
+
182
+ def _structured_session_payload(row: dict, tasks: list[dict] | None) -> dict:
183
+ """Map one `coding_session` row (+ its `task` rows) to the structured-ingest
184
+ endpoint's session shape (ENT-3634 / ENT-3633).
185
+
186
+ Field renames only — no new computation. `cached_tokens` is the one
187
+ exception, summing cinsights' two separate cache columns into the single
188
+ field the endpoint expects.
189
+ """
190
+ session: dict = {
191
+ wire: row[local] for local, wire in _SESSION_FIELD_MAP.items() if row.get(local) is not None
192
+ }
193
+ session["session_id"] = row.get("id")
194
+ cache_read = row.get("cache_read_tokens") or 0
195
+ cache_write = row.get("cache_write_tokens") or 0
196
+ if cache_read or cache_write:
197
+ session["cached_tokens"] = cache_read + cache_write
198
+ if tasks:
199
+ session["tasks"] = [
200
+ {wire: t[local] for local, wire in _TASK_FIELD_MAP.items() if t.get(local) is not None}
201
+ for t in tasks
202
+ ]
203
+ return session
204
+
138
205
 
139
206
  def _local_db_path() -> str | None:
140
207
  """Filesystem path of the local cinsights SQLite DB (where `index` writes)."""
@@ -222,15 +289,19 @@ def _sync_cmd(
222
289
  secrets_mode,
223
290
  limit,
224
291
  ):
225
- """Push your locally-computed coding-agent session metrics to Entelligence.
292
+ """Push your locally-computed coding-agent sessions to Entelligence (Path A).
226
293
 
227
- Refreshes the local index, then uploads the per-session metrics (tokens,
228
- tool usage, quality + efficiency scores) the same data the dashboard
229
- shows. Identity (org + you) is derived server-side from your CLI token, and
230
- re-runs upsert, so it's safe to cron daily.
294
+ Refreshes the local index, then uploads session + task data — the same
295
+ data the dashboard shows, and (ENT-3634) the same data that now lands on
296
+ the coding-agent graph, not just RDS metrics, via the structured-ingest
297
+ endpoint. Raw transcript text never leaves the machine. Identity (org +
298
+ you) is derived server-side from your CLI token, and re-runs upsert, so
299
+ it's safe to cron daily.
231
300
 
232
301
  With --transcripts, uploads the raw (redacted) transcript files to the store
233
- for the ingestion worker instead of pushing metrics.
302
+ for the ingestion worker instead of pushing structured data — Path B.
303
+ Mutually exclusive with the default push for now (ENT-3732 tracks making
304
+ the two independently schedulable).
234
305
  """
235
306
  obj = ctx.obj or {}
236
307
  api_client = obj.get("api_client")
@@ -314,12 +385,16 @@ def _sync_cmd(
314
385
  if name:
315
386
  bucket = skill_dist.setdefault(sr["session_id"], {})
316
387
  bucket[name] = bucket.get(name, 0) + 1
317
- # Per-session task segmentation (name + turn range + waste) — powers the
318
- # task bands on the context-growth chart. Names/numbers only.
388
+ # Per-session task segmentation. Every column cinsights' `task` table
389
+ # actually has (ENT-3634) description/turn_count/completion_tokens_total/
390
+ # context_at_start were dropped by the old SELECT, which only needed
391
+ # name/turn-range/waste for the context-growth chart; the structured-ingest
392
+ # endpoint (ENT-3631) needs the rest too.
319
393
  task_map: dict[str, list] = {}
320
394
  for tr in conn.execute(
321
- "SELECT session_id, task_number, name, start_turn, end_turn, "
322
- "prompt_tokens_total, estimated_waste_tokens FROM task ORDER BY task_number"
395
+ "SELECT session_id, task_number, name, description, start_turn, end_turn, "
396
+ "turn_count, prompt_tokens_total, completion_tokens_total, context_at_start, "
397
+ "estimated_waste_tokens FROM task ORDER BY task_number"
323
398
  ):
324
399
  task_map.setdefault(tr["session_id"], []).append(dict(tr))
325
400
  # LLM-generated per-session insights (category/title/content/severity).
@@ -331,8 +406,9 @@ def _sync_cmd(
331
406
  finally:
332
407
  conn.close()
333
408
 
334
- # 3. Keep sessions newer than the high-water mark (unless --all), trim to the
335
- # metric columns, and dedup on the engine's stable session key.
409
+ # 3. Keep sessions newer than the high-water mark (unless --all), map to the
410
+ # structured-ingest endpoint's field names (ENT-3634), and dedup on the
411
+ # engine's stable session key.
336
412
  hwm = None if push_all else _read_high_water_mark()
337
413
  sessions: list[dict] = []
338
414
  max_seen: datetime | None = None
@@ -343,21 +419,20 @@ def _sync_cmd(
343
419
  # re-upload every run). The first sync (no HWM) still includes them once.
344
420
  if not push_all and hwm is not None and (last is None or last <= hwm):
345
421
  continue
346
- session = {k: row.get(k) for k in _PUSH_COLUMNS if row.get(k) is not None}
347
- session["session_id"] = row.get("id") # stable engine key -> server dedups on it
422
+ session = _structured_session_payload(row, task_map.get(row.get("id")))
423
+ # Extras the structured-ingest endpoint doesn't read yet (ENT-3631 accepts
424
+ # and ignores them) — sent anyway so nothing here needs to change once the
425
+ # backend catches up (findings/coaching routing, Decision 1; RDS column
426
+ # parity for tool/skill distribution).
348
427
  td = tool_dist.get(row.get("id"))
349
428
  if td:
350
429
  session["tool_distribution"] = td
351
430
  sd = skill_dist.get(row.get("id"))
352
431
  if sd:
353
432
  session["skill_distribution"] = sd
354
- # Per-session drill-down material: context growth (per-turn), tasks, insights.
355
433
  cg = row.get("context_growth_json")
356
434
  if cg:
357
435
  session["context_growth"] = cg # already a JSON string in the engine DB
358
- tasks = task_map.get(row.get("id"))
359
- if tasks:
360
- session["tasks"] = tasks
361
436
  insights = insight_map.get(row.get("id"))
362
437
  if insights:
363
438
  session["insights"] = insights
@@ -376,24 +451,35 @@ def _sync_cmd(
376
451
  return
377
452
 
378
453
  pushed = 0
379
- stored_total = 0
454
+ sessions_stored_total = 0
455
+ tasks_written_total = 0
380
456
  client_version = _client_version()
381
457
  for i in range(0, len(sessions), batch_size):
382
458
  batch = sessions[i : i + batch_size]
383
459
  # orgUUID is only for the server's @require_org_access check; the backend
384
460
  # derives the real identity (org + dev) from the auth token, not the body.
385
- payload = {"orgUUID": org_uuid, "clientVersion": client_version, "sessions": batch}
386
- resp = api_client.ingest_agent_insights(org_uuid, payload)
461
+ payload = {
462
+ "orgUUID": org_uuid,
463
+ "clientVersion": client_version,
464
+ "extractionVersion": _STRUCTURED_EXTRACTION_VERSION,
465
+ "sessions": batch,
466
+ }
467
+ resp = api_client.ingest_agent_insights_structured(org_uuid, payload)
387
468
  if isinstance(resp, dict) and resp.get("Error"):
388
469
  raise click.ClickException(f"Sync failed after {pushed} session(s): {resp['Error']}")
389
- stored_total += int(resp.get("stored") or 0) if isinstance(resp, dict) else 0
470
+ if isinstance(resp, dict):
471
+ sessions_stored_total += int(resp.get("sessions_stored") or 0)
472
+ tasks_written_total += int(resp.get("tasks_written") or 0)
390
473
  pushed += len(batch)
391
474
  click.echo(f" pushed {pushed}/{len(sessions)}")
392
475
 
393
476
  if max_seen is not None:
394
477
  _write_high_water_mark(max_seen)
395
478
 
396
- click.echo(f"Synced {pushed} session(s) ({stored_total} stored) to Entelligence.")
479
+ click.echo(
480
+ f"Synced {pushed} session(s) ({sessions_stored_total} stored, "
481
+ f"{tasks_written_total} tasks) to Entelligence."
482
+ )
397
483
 
398
484
 
399
485
  # ----- auto-push: fire `sync` in the background, throttled to once/day -----
@@ -601,6 +687,17 @@ _RE_IDE_CONTEXT_TAG = re.compile(r"<ide_[^>]*>.*?</ide_[^>]*>", re.IGNORECASE |
601
687
  _RE_USER_QUERY_TAG = re.compile(r"</?user_query>", re.IGNORECASE)
602
688
 
603
689
 
690
+ def _kiro_homes() -> list[str]:
691
+ """Kiro home dirs to read session event logs from (~/.kiro).
692
+
693
+ Unlike the others there's no cinsights config to consult — the insights engine
694
+ has no Kiro source, so metrics for Kiro sessions come from the uploaded
695
+ transcript (``sync --transcripts``), not the local index. Prompts for the wrap
696
+ are read straight off disk here.
697
+ """
698
+ return [os.path.expanduser("~/.kiro")]
699
+
700
+
604
701
  def _strip_prompt_tags(text: str) -> str:
605
702
  """Drop `<ide_*>` blocks and unwrap Cursor's `<user_query>` tags."""
606
703
  text = _RE_IDE_CONTEXT_TAG.sub("", text)
@@ -686,6 +783,23 @@ def _user_prompt_text(obj: dict) -> str | None:
686
783
  txt = _codex_user_text((payload.get("message") or "").strip())
687
784
  if txt is None:
688
785
  return None
786
+ # --- Kiro -----------------------------------------------------------------
787
+ # Events are enveloped: {"id", "timestamp", "payload": {"type": ..., ...}} with
788
+ # no top-level "type" — that absence is what tells them apart from Codex, whose
789
+ # branches above match on their own line_type first.
790
+ elif not line_type and isinstance(obj.get("payload"), dict):
791
+ payload = obj["payload"]
792
+ ptype = payload.get("type")
793
+ if ptype == "user":
794
+ content = payload.get("content")
795
+ txt = content.strip() if isinstance(content, str) else None
796
+ elif ptype == "interaction_resolved":
797
+ # The developer answering a Kiro question — their words, so a real prompt.
798
+ selected = payload.get("selectedOption")
799
+ if payload.get("outcome") == "answered" and isinstance(selected, str):
800
+ txt = selected.strip()
801
+ if txt is None:
802
+ return None
689
803
  # --- Claude Code / Cursor -------------------------------------------------
690
804
  else:
691
805
  if line_type != "user" and obj.get("role") != "user":
@@ -737,6 +851,14 @@ def _extract_user_prompts(days: int, cap: int = _WRAP_MAX_PROMPTS) -> list[str]:
737
851
  for home in _codex_homes():
738
852
  files += glob.glob(os.path.join(home, "sessions", "**", "*.jsonl"), recursive=True)
739
853
  files += glob.glob(os.path.join(home, "archived_sessions", "**", "*.jsonl"), recursive=True)
854
+ # Kiro: one event log per session dir, <home>/sessions/<ws-hash>/<session-id>/
855
+ # messages.jsonl. Enveloped lines ({"payload": {"type": "user", ...}}) — handled
856
+ # in _user_prompt_text. Sub-executions are sub-agent turns, not the developer's,
857
+ # so they're deliberately not globbed here.
858
+ for home in _kiro_homes():
859
+ files += glob.glob(
860
+ os.path.join(home, "sessions", "*", "*", "messages.jsonl"),
861
+ )
740
862
  for f in files:
741
863
  try:
742
864
  if os.path.getmtime(f) < cutoff:
@@ -1127,6 +1127,217 @@ def _scan_sessions_in_parallel(sessions: list[Session], *, workers: int) -> None
1127
1127
  progress.advance(task)
1128
1128
 
1129
1129
 
1130
+ # ----- Discovery: Kiro (session directories) ---------------------------------
1131
+ #
1132
+ # A Kiro session is a DIRECTORY, not a file: sessions/<ws-hash>/<session-id>/ holds
1133
+ # session.json (metadata) + messages.jsonl (event log), and sub-agent runs land in
1134
+ # sub-executions/<execution-id>.jsonl. We synthesize one NDJSON per session — a
1135
+ # `kiro_session` header line then the raw events, merged by timestamp — mirroring
1136
+ # the cursor_store/opencode pattern so scan, redaction, upload, and dedup are all
1137
+ # unchanged downstream. The backend's `detect_agent` sniffs the header type.
1138
+
1139
+ KIRO_CACHE_ROOT = Path.home() / ".entelligence" / "cache" / "kiro"
1140
+
1141
+
1142
+ def _count_kiro_sessions(home: Path) -> int:
1143
+ """Count session dirs under <home>/sessions/<ws-hash>/<session-id>/ (0 if none)."""
1144
+ sessions_root = home / "sessions"
1145
+ if not sessions_root.is_dir():
1146
+ return 0
1147
+ n = 0
1148
+ try:
1149
+ for ws_hash_dir in sessions_root.iterdir():
1150
+ if not ws_hash_dir.is_dir():
1151
+ continue
1152
+ for session_dir in ws_hash_dir.iterdir():
1153
+ if session_dir.is_dir() and (session_dir / "session.json").is_file():
1154
+ n += 1
1155
+ except OSError:
1156
+ return n
1157
+ return n
1158
+
1159
+
1160
+ def _kiro_cwd(session_meta: dict) -> Path | None:
1161
+ """The session's primary workspace root (``workspacePaths[0]``), else None.
1162
+
1163
+ None means the repo pre-filter can't place the session by cwd and falls back to
1164
+ transcript-based recovery — see ``_partition_by_connected_repos``.
1165
+ """
1166
+ paths = session_meta.get("workspacePaths")
1167
+ if isinstance(paths, list) and paths:
1168
+ first = paths[0]
1169
+ if isinstance(first, str) and first.strip():
1170
+ return Path(first)
1171
+ return None
1172
+
1173
+
1174
+ def _kiro_read_events(path: Path) -> list[dict]:
1175
+ """Parse one Kiro JSONL event log; unparseable/non-dict lines are dropped."""
1176
+ events: list[dict] = []
1177
+ try:
1178
+ data = path.read_bytes()
1179
+ except OSError:
1180
+ return events
1181
+ for raw_line in data.split(b"\n"):
1182
+ raw_line = raw_line.strip()
1183
+ if not raw_line:
1184
+ continue
1185
+ try:
1186
+ obj = json.loads(raw_line)
1187
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
1188
+ continue
1189
+ if isinstance(obj, dict):
1190
+ events.append(obj)
1191
+ return events
1192
+
1193
+
1194
+ def _kiro_source_mtime(messages_mtime: float, session_dir: Path) -> float:
1195
+ """Latest mtime across every file `_kiro_synthesize` reads for this session.
1196
+
1197
+ That is ``messages.jsonl`` plus any ``sub-executions/*.jsonl``. The cache
1198
+ freshness guard compares against this so a sub-agent file updated after
1199
+ ``messages.jsonl`` still triggers re-synthesis — comparing against
1200
+ ``messages.jsonl`` alone would serve a cache missing those sub-agent events.
1201
+ A missing/unreadable ``sub-executions/`` dir falls back to ``messages_mtime``.
1202
+ """
1203
+ latest = messages_mtime
1204
+ sub_exec_dir = session_dir / "sub-executions"
1205
+ if sub_exec_dir.is_dir():
1206
+ try:
1207
+ for sub_file in sub_exec_dir.iterdir():
1208
+ if sub_file.suffix != ".jsonl":
1209
+ continue
1210
+ with contextlib.suppress(OSError):
1211
+ latest = max(latest, sub_file.stat().st_mtime)
1212
+ except OSError:
1213
+ pass
1214
+ return latest
1215
+
1216
+
1217
+ def _kiro_synthesize(session_meta: dict, messages_path: Path, session_dir: Path) -> bytes | None:
1218
+ """Build one session's NDJSON: `kiro_session` header + events, merged by timestamp.
1219
+
1220
+ Sub-execution (sub-agent) events are merged inline so the whole conversation —
1221
+ including sub-agent tool calls — travels as a single uploadable file.
1222
+
1223
+ Returns None when the session has no ``turn_end`` event: an empty or
1224
+ metadata-only session has nothing worth uploading.
1225
+ """
1226
+ events = _kiro_read_events(messages_path)
1227
+ sub_exec_dir = session_dir / "sub-executions"
1228
+ if sub_exec_dir.is_dir():
1229
+ try:
1230
+ sub_files = sorted(p for p in sub_exec_dir.iterdir() if p.suffix == ".jsonl")
1231
+ except OSError:
1232
+ sub_files = []
1233
+ for sub_file in sub_files:
1234
+ events.extend(_kiro_read_events(sub_file))
1235
+
1236
+ # Stable sort on timestamp: equal/absent timestamps keep file order (main
1237
+ # stream first, then each sub-execution in name order).
1238
+ events.sort(key=lambda e: str(e.get("timestamp") or ""))
1239
+
1240
+ has_turns = any(
1241
+ isinstance(e.get("payload"), dict) and e["payload"].get("type") == "turn_end"
1242
+ for e in events
1243
+ )
1244
+ if not has_turns:
1245
+ return None
1246
+
1247
+ # Marker key LAST so it always wins: if a future session.json carries its own
1248
+ # `type`, spreading it after would clobber the `kiro_session` marker and the
1249
+ # backend's detect_agent would fail to recognise the transcript (dropping it).
1250
+ header = {**session_meta, "type": "kiro_session"}
1251
+ lines = [json.dumps(header, separators=(",", ":"))]
1252
+ lines.extend(json.dumps(e, separators=(",", ":")) for e in events)
1253
+ return ("\n".join(lines) + "\n").encode()
1254
+
1255
+
1256
+ def find_kiro_candidates(home: HomeSpec, *, cache_root: Path | None = None) -> Iterator[Session]:
1257
+ """Yield a Session per Kiro session, synthesizing an NDJSON transcript.
1258
+
1259
+ ``home.path`` is the Kiro home (holds ``sessions/``). Each session is materialized
1260
+ to ``<cache_root>/<ws-hash>/<session-id>.jsonl`` and that file becomes the Session's
1261
+ ``abs_path``, mirroring ``find_cursor_store_candidates``.
1262
+
1263
+ The cache is only rewritten when ``messages.jsonl`` is newer than it, so a re-run
1264
+ over unchanged sessions costs a stat() rather than a full re-synthesis.
1265
+ """
1266
+ sessions_root = home.path / "sessions"
1267
+ if not sessions_root.is_dir():
1268
+ logger.debug("kiro: no sessions dir under %s", home.path)
1269
+ return
1270
+ root = cache_root or KIRO_CACHE_ROOT
1271
+ try:
1272
+ ws_hash_dirs = sorted(p for p in sessions_root.iterdir() if p.is_dir())
1273
+ except OSError as exc:
1274
+ logger.debug("kiro: cannot list %s: %s", sessions_root, exc)
1275
+ return
1276
+
1277
+ for ws_hash_dir in ws_hash_dirs:
1278
+ ws_hash = _safe_component(ws_hash_dir.name, "unknown")
1279
+ try:
1280
+ session_dirs = sorted(p for p in ws_hash_dir.iterdir() if p.is_dir())
1281
+ except OSError:
1282
+ continue
1283
+ for session_dir in session_dirs:
1284
+ session_json_path = session_dir / "session.json"
1285
+ messages_path = session_dir / "messages.jsonl"
1286
+ if not session_json_path.is_file() or not messages_path.is_file():
1287
+ continue
1288
+ try:
1289
+ session_meta = json.loads(session_json_path.read_bytes())
1290
+ except (OSError, json.JSONDecodeError, ValueError):
1291
+ logger.debug("kiro: bad session.json in %s", session_dir)
1292
+ continue
1293
+ if not isinstance(session_meta, dict):
1294
+ continue
1295
+ try:
1296
+ messages_mtime = messages_path.stat().st_mtime
1297
+ except OSError:
1298
+ continue
1299
+ # Freshness is judged against every file the synthesis reads — not
1300
+ # just messages.jsonl. Sub-executions are merged in too, and a
1301
+ # sub-agent file updated after messages.jsonl would otherwise leave
1302
+ # the cache "fresh" while missing those events.
1303
+ source_mtime = _kiro_source_mtime(messages_mtime, session_dir)
1304
+
1305
+ session_id = _safe_component(session_meta.get("id") or session_dir.name, "session")
1306
+ rel = Path(ws_hash) / f"{session_id}.jsonl"
1307
+ abs_path = root / rel
1308
+ cwd = _kiro_cwd(session_meta)
1309
+
1310
+ fresh = False
1311
+ with contextlib.suppress(OSError):
1312
+ fresh = abs_path.stat().st_mtime >= source_mtime
1313
+ if not fresh:
1314
+ data = _kiro_synthesize(session_meta, messages_path, session_dir)
1315
+ if data is None:
1316
+ continue
1317
+ try:
1318
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
1319
+ abs_path.write_bytes(data)
1320
+ except OSError as exc:
1321
+ logger.debug("kiro materialize failed %s: %s", abs_path, exc)
1322
+ continue
1323
+ try:
1324
+ st = abs_path.stat()
1325
+ except OSError:
1326
+ continue
1327
+ yield Session(
1328
+ agent=AgentKind.KIRO,
1329
+ home_label=home.label,
1330
+ abs_path=abs_path,
1331
+ rel_path=rel,
1332
+ size_bytes=st.st_size,
1333
+ # messages.jsonl mtime, not the cache file's: the cache is rewritten
1334
+ # on re-synthesis and would otherwise report every session as "just now".
1335
+ mtime=datetime.fromtimestamp(messages_mtime, tz=UTC),
1336
+ cwd=cwd,
1337
+ secret_findings=[],
1338
+ )
1339
+
1340
+
1130
1341
  def find_all_candidates(homes: Iterable[HomeSpec]) -> list[Session]:
1131
1342
  """Cheap pass: find every candidate session, no secret scan. Newest-first."""
1132
1343
  found: list[Session] = []
@@ -1141,6 +1352,8 @@ def find_all_candidates(homes: Iterable[HomeSpec]) -> list[Session]:
1141
1352
  elif home.kind is AgentKind.CURSOR:
1142
1353
  found.extend(find_cursor_store_candidates(home))
1143
1354
  cursor_homes_seen = True
1355
+ elif home.kind is AgentKind.KIRO:
1356
+ found.extend(find_kiro_candidates(home))
1144
1357
  # IDE composers live in state.vscdb (not under ~/.cursor). Discover once per
1145
1358
  # run when any Cursor home is configured — usage API joins on composerId.
1146
1359
  if cursor_homes_seen:
@@ -1325,14 +1538,15 @@ def _detect_candidate_homes(prefix: str, counter: Callable[[Path], int]) -> dict
1325
1538
  return out
1326
1539
 
1327
1540
 
1328
- def auto_detect_homes() -> tuple[list[Path], list[Path], list[Path], list[Path]]:
1329
- """Return (claude_homes, codex_homes, opencode_homes, cursor_homes) auto-detected
1330
- that hold transcripts."""
1541
+ def auto_detect_homes() -> tuple[list[Path], list[Path], list[Path], list[Path], list[Path]]:
1542
+ """Return (claude_homes, codex_homes, opencode_homes, cursor_homes, kiro_homes)
1543
+ auto-detected that hold transcripts."""
1331
1544
  claude = sorted(_detect_candidate_homes(".claude", _count_cc_sessions).keys())
1332
1545
  codex = sorted(_detect_candidate_homes(".codex", _count_codex_sessions).keys())
1333
1546
  opencode = sorted(_detect_opencode_homes().keys())
1334
1547
  cursor = sorted(_detect_candidate_homes(".cursor", _count_cursor_sessions).keys())
1335
- return claude, codex, opencode, cursor
1548
+ kiro = sorted(_detect_candidate_homes(".kiro", _count_kiro_sessions).keys())
1549
+ return claude, codex, opencode, cursor, kiro
1336
1550
 
1337
1551
 
1338
1552
  def homes_from_config(cfg: UploadConfig) -> list[HomeSpec]:
@@ -1362,4 +1576,10 @@ def homes_from_config(cfg: UploadConfig) -> list[HomeSpec]:
1362
1576
  console.print(f"[yellow]skip:[/yellow] cursor-home {p} no longer exists")
1363
1577
  continue
1364
1578
  homes.append(HomeSpec(kind=AgentKind.CURSOR, path=p.resolve()))
1579
+ for raw in cfg.kiro_homes:
1580
+ p = Path(raw).expanduser()
1581
+ if not p.is_dir():
1582
+ console.print(f"[yellow]skip:[/yellow] kiro-home {p} no longer exists")
1583
+ continue
1584
+ homes.append(HomeSpec(kind=AgentKind.KIRO, path=p.resolve()))
1365
1585
  return homes
@@ -22,6 +22,7 @@ class AgentKind(StrEnum):
22
22
  CODEX = "codex"
23
23
  OPENCODE = "opencode"
24
24
  CURSOR = "cursor"
25
+ KIRO = "kiro"
25
26
 
26
27
 
27
28
  class HomeSpec(BaseModel):
@@ -127,11 +128,19 @@ class UploadConfig(BaseModel):
127
128
  # chats/<ws-hash>/<uuid>/ or acp-sessions/<uuid>/, so discovery synthesizes an NDJSON
128
129
  # transcript per session (see discovery.py), same as opencode.
129
130
  cursor_homes: list[str] = Field(default_factory=list)
131
+ # Kiro homes (e.g. ~/.kiro). A session is a DIRECTORY — sessions/<ws-hash>/<session-id>/
132
+ # holding session.json + messages.jsonl (+ sub-executions/*.jsonl) — so discovery
133
+ # synthesizes one NDJSON transcript per session (see discovery.py), same as cursor/opencode.
134
+ kiro_homes: list[str] = Field(default_factory=list)
130
135
  secrets_mode: Literal["redact", "allow", "skip"] = Field(default="redact")
131
136
  skip_prefixes: list[str] = Field(default_factory=list)
132
137
  entelligence: EntelligenceIdentity | None = None
133
138
 
134
139
  def needs_setup(self) -> bool:
135
140
  return not (
136
- self.claude_homes or self.codex_homes or self.opencode_homes or self.cursor_homes
141
+ self.claude_homes
142
+ or self.codex_homes
143
+ or self.opencode_homes
144
+ or self.cursor_homes
145
+ or self.kiro_homes
137
146
  )
@@ -372,13 +372,14 @@ def _backfill_missing_home_kinds(cfg: UploadConfig) -> bool:
372
372
  Returns:
373
373
  True if any kind was backfilled (caller should persist), else False.
374
374
  """
375
- cc, cx, oc, cu = auto_detect_homes()
375
+ cc, cx, oc, cu, ki = auto_detect_homes()
376
376
  added = False
377
377
  for detected, attr in (
378
378
  (cc, "claude_homes"),
379
379
  (cx, "codex_homes"),
380
380
  (oc, "opencode_homes"),
381
381
  (cu, "cursor_homes"),
382
+ (ki, "kiro_homes"),
382
383
  ):
383
384
  if not getattr(cfg, attr) and detected:
384
385
  setattr(cfg, attr, [str(p) for p in detected])
@@ -413,12 +414,13 @@ def run_transcript_upload(
413
414
  if setup or (
414
415
  cfg.needs_setup() and not (claude_home or codex_home or opencode_home or cursor_home)
415
416
  ):
416
- cc, cx, oc, cu = auto_detect_homes()
417
+ cc, cx, oc, cu, ki = auto_detect_homes()
417
418
  cfg.claude_homes = [str(p) for p in cc]
418
419
  cfg.codex_homes = [str(p) for p in cx]
419
420
  cfg.opencode_homes = [str(p) for p in oc]
420
421
  cfg.cursor_homes = [str(p) for p in cu]
421
- if cc or cx or oc or cu:
422
+ cfg.kiro_homes = [str(p) for p in ki]
423
+ if cc or cx or oc or cu or ki:
422
424
  save_config(cfg)
423
425
  elif not (claude_home or codex_home or opencode_home or cursor_home) and (
424
426
  _backfill_missing_home_kinds(cfg)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entelligence-cli
3
- Version: 0.1.97
3
+ Version: 0.1.99
4
4
  Summary: AI-powered code review assistant for your terminal - Get instant, intelligent feedback on your code changes
5
5
  Author-email: EntelligenceAI <info@entelligence.ai>
6
6
  Project-URL: Homepage, https://entelligence.ai
@@ -68,7 +68,9 @@ tests/test_cursor_usage.py
68
68
  tests/test_discovery_head_unicode.py
69
69
  tests/test_discovery_opencode.py
70
70
  tests/test_incident_smoke.py
71
+ tests/test_insights_kiro_prompts.py
71
72
  tests/test_insights_me_smoke.py
73
+ tests/test_insights_structured_payload.py
72
74
  tests/test_pii.py
73
75
  tests/test_presigned_skip_index.py
74
76
  tests/test_router_commands.py
@@ -76,5 +78,6 @@ tests/test_security_commands.py
76
78
  tests/test_update_command.py
77
79
  tests/test_upload_cursor_store.py
78
80
  tests/test_upload_home_backfill.py
81
+ tests/test_upload_kiro.py
79
82
  tests/test_upload_repo_filter.py
80
83
  tests/test_upload_repo_roots.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "entelligence-cli"
7
- version = "0.1.97"
7
+ version = "0.1.99"
8
8
  description = "AI-powered code review assistant for your terminal - Get instant, intelligent feedback on your code changes"
9
9
  readme = "README.md"
10
10
  authors = [