Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
676 changes: 676 additions & 0 deletions docs/superpowers/plans/2026-08-05-gemini-analytics-fix.md

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions docs/superpowers/specs/2026-08-05-gemini-analytics-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Design: Add Gemini CLI to Analytics Report

**Date**: 2026-08-05
**Ticket**: EPMCDME-13909
**Status**: Approved

## Problem

`codemie analytics` ignores codemie-gemini session data. The Gemini plugin (`src/agents/plugins/gemini/`) is fully registered and has a session adapter that can parse session files, but `GeminiSessionAdapter` never implemented `discoverSessions()`. The native-loader therefore skips it silently — `NATIVE_AGENTS` does not include `'gemini'`, so discovery is never attempted.

## Solution

Implement `discoverSessions()` on `GeminiSessionAdapter` and add `'gemini'` to `NATIVE_AGENTS`. This slots gemini into the established three-layer analytics pattern used by every other agent (claude, codex, copilot-cli).

## Architecture

Five files change; no new abstractions beyond a small paths helper.

| File | Change |
|---|---|
| `src/agents/plugins/gemini/gemini.paths.ts` | **New** — path helpers for discovery, mirrors `copilot-cli.paths.ts` |
| `src/agents/plugins/gemini/gemini.session-adapter.ts` | Add `discoverSessions(options?)` method |
| `src/cli/commands/analytics/native-loader.ts` | Add `'gemini'` to `NATIVE_AGENTS` |
| `src/cli/commands/analytics/agent-labels.ts` | Add `'gemini': 'Gemini CLI'` |
| `src/cli/commands/analytics/report/client/app.js` | Add `gemini` entry to inline `AGENT_LABELS` and `AGENT_COLORS` |

The `synthesizeRawSession` path in `native-loader.ts` handles non-Codex agents without changes. `projectPath` will be `undefined` from the descriptor (no reverse-hash mapping exists in the Gemini CLI); the loader falls back to `'Unknown'`.

## Components

### `gemini.paths.ts` (new, ~15 lines)

```ts
export function getGeminiHome(): string {
return process.env.GEMINI_HOME?.trim() || resolveHomeDir('.gemini');
}

export function getGeminiTmpRoot(): string {
return join(getGeminiHome(), 'tmp');
}
```

Respects `GEMINI_HOME` environment override, consistent with how `copilot-cli.paths.ts` respects `COPILOT_HOME`.

### `discoverSessions()` on `GeminiSessionAdapter` (~60 lines)

Gemini session files live at `~/.gemini/tmp/{projectHash}/chats/{sessionId}.json`. Each file is a self-contained JSON object with `sessionId`, `projectHash`, `startTime`, `lastUpdated`, and `messages[]`.

Discovery algorithm:
1. Read `~/.gemini/tmp/` — each entry is a hash directory (one per project)
2. For each hash dir, read `chats/` — each `*.json` is a session file
3. Read only the header fields (`sessionId`, `startTime`, `lastUpdated`) — no full parse at discovery time
4. Apply `maxAgeDays` cutoff on `startTime`; skip files that fail JSON parse (never throw)
5. Return `SessionDescriptor[]` with:
- `sessionId` — from file content
- `filePath` — absolute path to the `.json` file
- `createdAt` — `Date.parse(startTime)` in ms
- `updatedAt` — `Date.parse(lastUpdated)` in ms
- `projectPath` — `undefined` (no reverse hash mapping available)

### `NATIVE_AGENTS` in `native-loader.ts`

```ts
const NATIVE_AGENTS = ['claude', 'codex', 'copilot-cli', 'gemini'] as const;
```

### Labels

`agent-labels.ts`:
```ts
const AGENT_LABELS: Record<string, string> = {
'copilot-cli': 'GitHub Copilot CLI',
'gemini': 'Gemini CLI',
};
```

`src/cli/commands/analytics/report/client/app.js` — add matching entries to the inline `AGENT_LABELS` object and pick a color for `AGENT_COLORS` from the existing palette (the report handles unknown agents gracefully, so this is cosmetic).

## Data Flow

```
getGeminiTmpRoot()
→ readdirSync(tmp/) [hash directories, one per project]
→ readdirSync(hash/chats/) [*.json session files]
→ read header → SessionDescriptor
→ native-loader dedup (skip if already tracked by CodeMie)
→ parseSessionFile() (existing GeminiSessionAdapter method)
→ synthesizeRawSession()
→ aggregator → formatter / HTML report
```

## Error Handling

- Absent `~/.gemini/tmp` → return `[]` (same as copilot-cli when its directory is missing)
- Unreadable hash directory or `chats/` subdirectory → log at `debug`, skip, continue
- Malformed JSON in a session file → log at `debug`, skip that file, continue
- Empty `messages[]` → `parseSessionFile` already handles this gracefully (returns a valid `ParsedSession` with empty metrics)
- No `GEMINI_HOME` set → default to `resolveHomeDir('.gemini')`

No error ever propagates out of `discoverSessions()`.

## Testing

Three new test files, mirroring the copilot-cli test split:

### `src/agents/plugins/gemini/__tests__/gemini.discovery.test.ts`

Unit tests for `discoverSessions` using injected/mocked filesystem:
- Empty `tmp/` dir → returns `[]`
- `tmp/` does not exist → returns `[]`
- `chats/` missing from hash dir → skips that dir
- Malformed JSON in `chats/` → skips that file, returns others
- `maxAgeDays` cutoff → old sessions excluded, recent ones included
- `GEMINI_HOME` env override → uses custom path
- Valid session files → returns correct `SessionDescriptor` fields

### `src/cli/commands/analytics/__tests__/native-loader.test.ts`

Extend existing file with a gemini case:
- Gemini session discovered and synthesized into `RawSessionData`
- Already-tracked gemini session is deduped (not double-counted)

Uses existing fixture files from `tests/integration/metrics/fixtures/gemini/`.

### `src/cli/commands/analytics/__tests__/agent-labels.test.ts`

Verify `agentLabel('gemini')` returns `'Gemini CLI'`; `agentLabel('unknown-agent')` returns `'unknown-agent'` unchanged.

## Acceptance Criteria Mapping

| Criterion | How satisfied |
|---|---|
| `codemie analytics` includes gemini data | `'gemini'` in `NATIVE_AGENTS` + `discoverSessions` implemented |
| Aggregated consistently with other agents | Same `synthesizeRawSession` → aggregator path |
| Report reflects Gemini sessions/usage | Label + (optional) color in report client |
| No regression on existing agents | Only additive changes; existing `NATIVE_AGENTS` entries unchanged |
| Validated with codemie-gemini session dataset | Integration test uses fixture files; CI gate |
| Graceful empty-state when no Gemini data | `discoverSessions` returns `[]` when tmp dir absent |

## Out of Scope

- Resolving `projectHash` → project path (no reverse mapping exists in Gemini CLI)
- Cost enrichment for gemini sessions (no pricing data available yet; can be added separately)
- Changes to codemie-tracked (hook-driven) gemini session processing
35 changes: 35 additions & 0 deletions docs/superpowers/work-items/EPMCDME-13909.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Work Item: EPMCDME-13909

**External Ticket**: https://jiraeu.epam.com/browse/EPMCDME-13909
**Type**: Bug
**Status**: In Progress
**Assignee**: Aleksandr Budanov
**External Sync**: succeeded

## Summary

codemie analytics report does not include codemie-gemini data

## Description

The `codemie analytics` command in CodeMie CLI currently ignores codemie-gemini data and does not include it in the generated analytics report. Users expect a complete analytics report across all supported CodeMie CLI agents; data produced by codemie-gemini is excluded, which makes analytics incomplete and reduces visibility into Gemini-based CLI usage.

## Acceptance Criteria

- codemie analytics includes codemie-gemini data in the generated report.
- Gemini data is aggregated consistently with other supported agents.
- The report clearly reflects Gemini sessions/usage when such data exists.
- Existing analytics reporting for other agents is not regressed.
- The fix is validated with at least one available codemie-gemini session dataset.
- If no Gemini data exists, the report behavior remains clear and does not fail.

## Linked Artifacts

- `docs/superpowers/runs/20260805-0528-EPMCDME-13909/requirements.md` — requirements (Phase 1, run 20260805-0528-EPMCDME-13909)

## History

| Date | Event | Actor | Notes |
|---|---|---|---|
| 2026-08-05 | work_item.created | requirements-intake | Created from Jira ticket EPMCDME-13909 via codemie-jira-assistant adapter |
| 2026-08-05 | work_item.linked_artifact | requirements-intake | Linked requirements.md from run 20260805-0528-EPMCDME-13909 |
172 changes: 172 additions & 0 deletions src/agents/plugins/gemini/__tests__/gemini.discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { GeminiSessionAdapter } from '../gemini.session-adapter.js';
import { GeminiPluginMetadata } from '../gemini.plugin.js';

let geminiHome: string;
const DAY = 24 * 60 * 60 * 1000;

/**
* Creates ~/.gemini/tmp/{hash}/chats/{sessionId}.json with well-formed content.
* Returns the absolute path to the created file.
*/
function makeSession(
hash: string,
sessionId: string,
startTime: number,
opts: { lastUpdated?: number } = {}
): string {
const chatsDir = join(geminiHome, 'tmp', hash, 'chats');
mkdirSync(chatsDir, { recursive: true });
const filePath = join(chatsDir, `${sessionId}.json`);
writeFileSync(
filePath,
JSON.stringify({
sessionId,
projectHash: hash,
startTime: new Date(startTime).toISOString(),
lastUpdated: new Date(opts.lastUpdated ?? startTime + 1000).toISOString(),
messages: [],
})
);
return filePath;
}

function newAdapter(): GeminiSessionAdapter {
return new GeminiSessionAdapter(GeminiPluginMetadata);
}

beforeEach(() => {
geminiHome = mkdtempSync(join(tmpdir(), 'gemini-home-'));
process.env.GEMINI_HOME = geminiHome;
});

afterEach(() => {
delete process.env.GEMINI_HOME;
rmSync(geminiHome, { recursive: true, force: true });
});

describe('GeminiSessionAdapter.discoverSessions', () => {
it('returns [] when tmp dir does not exist', async () => {
expect(await newAdapter().discoverSessions!()).toEqual([]);
});

it('returns [] when tmp dir exists but is empty', async () => {
mkdirSync(join(geminiHome, 'tmp'), { recursive: true });
expect(await newAdapter().discoverSessions!()).toEqual([]);
});

it('honors GEMINI_HOME and sets correct filePath', async () => {
const filePath = makeSession('abc123', 'sess-1', Date.now() - DAY);

const found = await newAdapter().discoverSessions!();

expect(found).toHaveLength(1);
expect(found[0].sessionId).toBe('sess-1');
expect(found[0].filePath).toBe(filePath);
expect(found[0].agentName).toBe('gemini');
expect(found[0].projectPath).toBeUndefined();
expect(found[0].updatedAt).toBeGreaterThan(found[0].createdAt);
});

it('skips hash dirs with no chats/ subdirectory', async () => {
const now = Date.now();
makeSession('has-chats', 'sess-a', now - DAY);
// hash dir with no chats/ subdir
mkdirSync(join(geminiHome, 'tmp', 'no-chats'), { recursive: true });

const found = await newAdapter().discoverSessions!();

expect(found).toHaveLength(1);
expect(found[0].sessionId).toBe('sess-a');
});

it('skips malformed JSON files and includes valid ones', async () => {
const now = Date.now();
makeSession('hash1', 'good-sess', now - DAY);
const badChatsDir = join(geminiHome, 'tmp', 'hash2', 'chats');
mkdirSync(badChatsDir, { recursive: true });
writeFileSync(join(badChatsDir, 'bad.json'), '{ not valid json');

const found = await newAdapter().discoverSessions!();

expect(found.map((d) => d.sessionId)).toEqual(['good-sess']);
});

it('honors maxAgeDays and excludes old sessions', async () => {
const now = Date.now();
makeSession('h1', 'recent', now - 2 * DAY);
makeSession('h2', 'ancient', now - 90 * DAY);

const found = await newAdapter().discoverSessions!({ maxAgeDays: 30 });

expect(found.map((d) => d.sessionId)).toEqual(['recent']);
});

it('defaults to a 30-day window', async () => {
const now = Date.now();
makeSession('h1', 'inside', now - 10 * DAY);
makeSession('h2', 'outside', now - 45 * DAY);

const found = await newAdapter().discoverSessions!();

expect(found.map((d) => d.sessionId)).toEqual(['inside']);
});

it('sorts newest-first', async () => {
const now = Date.now();
makeSession('h1', 'older', now - 5 * DAY);
makeSession('h2', 'newer', now - 1 * DAY);
makeSession('h3', 'middle', now - 3 * DAY);

const found = await newAdapter().discoverSessions!();

expect(found.map((d) => d.sessionId)).toEqual(['newer', 'middle', 'older']);
});

it('applies limit after sort', async () => {
const now = Date.now();
makeSession('h1', 'older', now - 5 * DAY);
makeSession('h2', 'newer', now - 1 * DAY);
makeSession('h3', 'middle', now - 3 * DAY);

const found = await newAdapter().discoverSessions!({ limit: 2 });

expect(found.map((d) => d.sessionId)).toEqual(['newer', 'middle']);
});

it('excludes timestampless sessions by default', async () => {
const chatsDir = join(geminiHome, 'tmp', 'hash-no-ts', 'chats');
mkdirSync(chatsDir, { recursive: true });
writeFileSync(
join(chatsDir, 'no-ts.json'),
JSON.stringify({ sessionId: 'no-ts', projectHash: 'hash-no-ts', messages: [] })
);

expect(await newAdapter().discoverSessions!()).toEqual([]);
});

it('includes timestampless sessions when asked', async () => {
const chatsDir = join(geminiHome, 'tmp', 'hash-no-ts', 'chats');
mkdirSync(chatsDir, { recursive: true });
writeFileSync(
join(chatsDir, 'no-ts.json'),
JSON.stringify({ sessionId: 'no-ts', projectHash: 'hash-no-ts', messages: [] })
);

const found = await newAdapter().discoverSessions!({ includeTimestampless: true });
expect(found.map((d) => d.sessionId)).toEqual(['no-ts']);
});

it('discovers sessions across multiple hash directories', async () => {
const now = Date.now();
makeSession('hash-a', 'sess-a', now - 1 * DAY);
makeSession('hash-b', 'sess-b', now - 2 * DAY);

const found = await newAdapter().discoverSessions!();

expect(found.map((d) => d.sessionId)).toEqual(['sess-a', 'sess-b']);
});
});
23 changes: 23 additions & 0 deletions src/agents/plugins/gemini/gemini.paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Gemini CLI storage locations.
*
* Gemini honors `GEMINI_HOME` as an override of `~/.gemini`. Discovery that ignores it
* silently returns zero sessions for anyone who sets it.
*/

import { join } from 'path';
import { resolveHomeDir } from '../../../utils/paths.js';

/** `~/.gemini`, or `$GEMINI_HOME` when set. */
export function getGeminiHome(): string {
const override = process.env.GEMINI_HOME?.trim();
if (override) {
return override;
}
return resolveHomeDir('.gemini');
}

/** Root of per-project session hash directories: `<geminiHome>/tmp`. */
export function getGeminiTmpRoot(): string {
return join(getGeminiHome(), 'tmp');
}
Loading
Loading