coding-agent-sessions
MUST USE when asked to find, read, list, search, inspect, fetch, export, or reconstruct coding-agent sessions across Codex, Claude Code/Desktop, OpenCode, OMO/Senpi/pi, oh-my-pi (omp), gajae-code (gjc), OpenClaw, Factory Droid, Amp, Gemini/Kimi/Qwen CLIs, Codebuff, Roo/Kilo/Cline
Install
npx skills add https://github.com/code-yeongyu/oh-my-openagent/tree/dev/packages/shared-skills/skills/coding-agent-sessions
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install code-yeongyu-oh-my-openagent@llmmart
git clone https://github.com/code-yeongyu/oh-my-openagent.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole code-yeongyu/oh-my-openagent collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Coding Agent Sessions
Find local coding-agent sessions across agent products before answering from memory. Prefer the bundled finder for broad cross-platform search, then read the selected session or raw file when you need exact evidence.
PHASE 0 - PLATFORM ROUTER
IF the user names a platform, load its reference first.
Platform Read Codex / OpenAI Codex CLI references/codex.mdClaude Code / Claude Desktop histories references/claude.mdOMO / Senpi / pi coding-agent logs references/senpi.mdoh-my-pi ( omp,~/.omp) and gajae-code (gjc,~/.gjc) logsreferences/senpi.mdOpenCode / oh-my-openagent (formerly oh-my-opencode) storage references/opencode.mdOpenClaw, Droid, Amp, Gemini, Kimi, Qwen, Codebuff, Roo/Kilo/Cline, Kodu, Cursor CLI, Aider, Kiro, Goose, Hermes, Crush, Zed, Aside references/all-platforms.mdUnknown / "any session" / cross-agent search references/all-platforms.mdRun the broad finder first unless the user gave an exact file path. For fuzzy recall, expand the query first.
When the user remembers a task vaguely ("that OpenCode bug", "the dashboard PR", "when did we fix X"), derive 3-6 short query lanes before searching: product/tool aliases, repo/package names, exact error text, issue/PR/session IDs, English/Korean phrasing, and likely verbs such as fix, review, plan, deploy, or merge. Run the lanes together with repeated --query so match_reasons shows which wording found the hit.
python3 scripts/find-agent-sessions.py list --limit 20
python3 scripts/find-agent-sessions.py find "commit" --from 7d --platform senpi --platform opencode
python3 scripts/find-agent-sessions.py find "proxy" --platform openclaw --platform droid --platform amp
python3 scripts/find-agent-sessions.py find "refactor" --platform oh-my-pi --platform gajae-code
python3 scripts/find-agent-sessions.py find --query "deploy" --query "token usage" --workers 64
python3 scripts/find-agent-sessions.py find --query "opencode bug" --query "fix opencode" --query "OpenCode parent session" --include-subagents --workers 64
python3 scripts/find-agent-sessions.py read <session-id>
Use python instead of python3 on systems where that is the available executable.
- Use explorer-style parallel lanes when one query batch is not enough.
If scope is broad (multi-month, many repos/platforms, or a vague "what happened with X"), split independent searches by names/errors, repos/cwds, platforms/models, and time windows. Use available subagent/delegation tools for these lanes when they exist; otherwise run the finder calls in parallel. Merge candidates by id/path, then read the most likely sessions.
Read details from search results before ad hoc digging. Search results include
detail_hint; run thatread <session-id> --platform <platform>command to see the first user prompt, last user prompt, events, and child sessions together.Verify by opening raw transcripts for claims. The finder normalizes formats; the raw
pathremains the source of truth.
Output Contract
The finder prints JSON for stdout and jq. Every result includes:
| Field | Meaning |
|---|---|
platform |
Registered platform key such as codex, claude, senpi, oh-my-pi, gajae-code, opencode, openclaw, droid, amp, kodu, cursor-cli, aider, roo-code, kilo-code, kilo-cli, kiro, or aside |
id |
Session ID or stable file-derived ID |
path |
Raw transcript/index file |
cwd |
Working directory when recoverable |
created_at, updated_at |
ISO-like timestamps when recoverable |
provider, model |
Model metadata when recoverable |
first_user_message |
First user prompt preview (for subagents: task description + delegated prompt) |
last_user_message |
Last user prompt preview when recoverable |
usage |
Token/cost clues when present in the platform log |
parent_id |
Parent session/thread ID when this is a subagent or child session, else null |
agent |
Subagent label (Claude agentType, Codex nickname (role), OpenCode agent name) |
subagent_count |
Number of child sessions spawned by this session |
detail_hint |
Ready-to-run read command for detailed inspection |
match_reasons |
Search-only array explaining which field/content matched each query |
Each match_reasons entry includes query, platform, field, and snippet, so you can tell which platform matched and what content caused the hit without opening every transcript.
Filters
list and search share these filters:
| Filter | Meaning |
|---|---|
--platform |
Repeatable platform filter; pass one platform per flag |
--root |
Extra root to scan, repeatable |
--from, --to |
Date bounds: YYYY-MM-DD, YYYY-MM, YYYY, today, yesterday, 7d |
--cwd |
Working-directory substring |
--model |
Model substring |
--limit |
Maximum results |
--query |
Repeatable search query; multiple queries return per-query groups plus a de-duplicated merged result list |
--workers |
Parallel worker count for platform scans, transcript parsing, OpenCode message joins, and multi-query matching |
--include-subagents |
Include subagent/child sessions as standalone list/search results (hidden by default) |
When --platform is omitted, the finder searches every registered platform in parallel. Each optional platform first probes fixed, known transcript roots and returns immediately when the product has no local store, so broad default searches stay cheap. Use repeatable flags such as --platform openclaw --platform droid only when narrowing. Comma-separated platform values are intentionally unsupported.
For OpenCode, the finder uses opencode db path plus direct SQLite queries first, then opencode session list --format json as a fallback. It avoids heavy messages/ or parts/ scans during normal list/search, and only falls back to file joins when the OpenCode DB/CLI is unavailable or explicit --root values request a nonstandard store.
Usage-only sources such as Copilot OTEL, Mux, Antigravity tokscale cache rows, Synthetic provider retagging, and Cursor IDE usage CSV are excluded from default transcript search because they do not reconstruct user prompts.
Subagent / Child Sessions
list and find/search return main sessions only by default, each annotated with subagent_count. read <main-session-id> (alias: get) always returns a prompts object and a subagents array containing every child session (id, agent label, parent_id, prompt preview, raw path), so opening a main session reveals its whole delegation tree. read <child-id> works too and returns that child's own events.
| Platform | Where children live | Linkage |
|---|---|---|
| Claude Code | projects/<proj>/<session-id>/subagents/agent-*.jsonl (Task tool) and .../subagents/workflows/wf_*/agent-*.jsonl (Workflow) |
Directory name = parent session ID; agent-*.meta.json holds agentType + task description |
| Codex | Regular threads in state_*.sqlite + own rollout JSONL |
thread_spawn_edges table and threads.source / rollout session_meta.payload.source.subagent.thread_spawn |
| OpenCode | Regular sessions in opencode.db / storage/session/ |
session.parent_id column / parentID field; agent column names the subagent |
When the user asks whether some specific work was ever done, search with --include-subagents — delegated work often lives only in child transcripts, not in the main session. Workflow journal.jsonl files are orchestration logs, not sessions.
Codex Notes
For Codex sessions, use the same broad finder. It reads state_*.sqlite, rollout JSONL, and archived rollout files:
python3 scripts/find-agent-sessions.py list --platform codex --from 7d
python3 scripts/find-agent-sessions.py find "deploy" --platform codex
python3 scripts/find-agent-sessions.py read <session-id> --platform codex
Use references/codex.md for Codex storage details.
Troubleshooting
| Problem | Fix |
|---|---|
| Missing Codex sessions | Set CODEX_HOME or pass --root /path/to/.codex. |
| Missing oh-my-pi / gajae-code sessions | Those stores live in ~/.omp/agent/sessions and ~/.gjc/agent/sessions. For a custom PI_CONFIG_DIR / PI_CODING_AGENT_DIR, pass that agent dir with --root. |
| Missing OMO / Senpi sessions | The senpi platform searches ~/.omo/agent/sessions, ~/.senpi/agent/sessions, and ~/.pi/agent/sessions (plus matching profile roots). Pass a nonstandard agent directory with --root. |
| Missing OpenCode sessions | Pass the data dir that contains messages/ and parts/, often ~/.opencode or ~/.local/share/opencode. |
| Missing Claude sessions | Search ~/.claude/projects, ~/.claude/transcripts, and ~/.claude/pre-compact-session-histories; use --root for nonstandard config dirs. |
| Missing Aside sessions | The Aside browser agent stores per-user data under ~/.aside/u/<n>/ (sessions/<date>_<id>/messages.jsonl transcripts + a state.db index; agents/*/sessions/ is just a hardlink mirror). Pass --root for a nonstandard .aside dir or an exported user dir. |
| Missing optional platform sessions | Check references/all-platforms.md for the exact local store. For project-local tools such as Aider, pass --root /path/to/workspace if the repo is outside the bounded default roots. |
| Date filter misses local sessions | Timestamps are compared as UTC instants when parseable; otherwise file mtime is used. |
| Search is slow | Narrow with repeated --platform flags or date/cwd filters. Optional stores are probed before parsing; avoid passing your whole home as --root unless you really want every bounded project-local scan. |
Activation
Use this skill for any request to find, read, or inspect a local coding-agent session, regardless of product name — including memory-recall questions ("what did I work on a few days ago", "did we already migrate X", "when did I fix Y"). If the user only says "that session where we did X", do not rely on one literal query: expand to multiple discriminative terms first, add --include-subagents when the work may have been delegated, then narrow by --platform, cwd, model, and time (--from 7d for "a few days ago"). Prefer the detail_hint from the chosen search result for the next read step. If the first query batch is still ambiguous, use explorer-style lanes by keyword, repo/cwd, platform/model, and time window before summarizing.
Files (oh-my-openagent)
-
agents
-
openai.yaml 226 B
interface: display_name: "Coding Agent Sessions" short_description: "Find local coding-agent session history" default_prompt: "Use $coding-agent-sessions to find the coding-agent session where I worked on a recent bug."
-
-
references
-
all-platforms.md 5.9 KB
# Cross-Platform Session Search ## Default locations Search these first, then add user-supplied roots with `--root`: Registered platform keys: `codex`, `claude`, `senpi`, `oh-my-pi`, `gajae-code`, `opencode`, `openclaw`, `droid`, `amp`, `gemini`, `kimi`, `qwen`, `codebuff`, `roo-code`, `kilo-code`, `cline`, `kodu`, `cursor-cli`, `aider`, `kilo-cli`, `hermes`, `goose`, `crush`, `zed`, `kiro`, `aside`. | Platform | Unix/macOS | Windows | |---|---|---| | Codex | `$CODEX_HOME`, `~/.codex` | `%CODEX_HOME%`, `%USERPROFILE%\.codex` | | Claude | `~/.claude` | `%USERPROFILE%\.claude`, `%APPDATA%\Claude` | | OMO / Senpi / pi | `~/.omo/agent`, `~/.senpi/agent`, `~/.pi/agent` | `%USERPROFILE%\.omo\agent`, `%USERPROFILE%\.senpi\agent`, `%USERPROFILE%\.pi\agent` | | oh-my-pi (`omp`) | `~/.omp/agent`, `~/.omp/profiles/*/agent`, `$XDG_DATA_HOME/omp` | `%USERPROFILE%\.omp\agent` | | gajae-code (`gjc`) | `~/.gjc/agent`, `~/.gjc/profiles/*/agent`, `$XDG_DATA_HOME/gjc` | `%USERPROFILE%\.gjc\agent` | | OpenCode | `$OPENCODE_HOME`, `~/.opencode`, `~/.local/share/opencode` | `%OPENCODE_HOME%`, `%APPDATA%\opencode`, `%USERPROFILE%\.opencode` | | OpenClaw | `~/.openclaw/agents/*/sessions`, `~/.openclaw/session-backups` | pass `--root` | | Factory Droid | `~/.factory/sessions/*/*.jsonl` | pass `--root` | | Amp | `~/.local/share/amp/threads/T-*.json` | pass `--root` | | Gemini / Kimi / Qwen | `~/.gemini/tmp/*/chats`, `~/.kimi/sessions/*/*/wire.jsonl`, `~/.qwen/projects/*/chats` | pass `--root` | | Codebuff | `~/.config/manicode*/projects/*/chats/*/chat-messages.json` | pass `--root` | | Roo Code (`roo-code`) / Kilo Code (`kilo-code`) / Cline | VS Code `globalStorage/<extension>/tasks/*` | VS Code `globalStorage\<extension>\tasks\*` | | Kodu | VS Code `globalStorage/kodu-ai.claude-dev-experimental/db/Azad.db` | VS Code `globalStorage\kodu-ai.claude-dev-experimental\db\Azad.db` | | Cursor CLI | `~/.cursor/chats/*/*/store.db`, `~/.cursor/prompt_history.json` | `%USERPROFILE%\.cursor\chats` | | Aider | bounded project roots containing `.aider.chat.history.md`; use `--root` for other repos | pass `--root` | | Kilo CLI (`kilo-cli`) / Hermes / Goose / Crush / Zed | Known SQLite roots are probed cheaply; unsupported schemas return empty | pass `--root` | | Kiro | `~/.kiro/sessions/cli/*.json` plus paired `*.jsonl` prompt events | pass `--root` | | Aside (browser agent) | `~/.aside/u/*/sessions/<date>_<id>/messages.jsonl`, joined with the per-user `state.db` `sessions` table (title, parent_id, cwd, model, timestamps). `~/.aside/u/*/agents/*/sessions/` is a hardlink mirror of `sessions/` and is intentionally not scanned | pass `--root` | ## Excluded usage-only sources Do not add these as default transcript platforms without a separate prompt-reconstruction source: | Source | Why excluded | |---|---| | Copilot OTEL | Token/telemetry rows, not prompts | | Mux | `session-usage.json` usage buckets only | | Antigravity | tokscale cache/RPC data, not raw chat transcripts | | Synthetic | Provider retagging over another platform, not an independent session store | | Cursor IDE usage CSV | Usage accounting; use `cursor-cli` for local CLI chat stores | ## Workflow 1. Run `scripts/find-agent-sessions.py search <query>` across all platforms, or repeat `--query` for several searches in one scan. Add `--include-subagents` when the work may have run inside a delegated agent — child transcripts are excluded from `list`/`search` by default. 2. If results are noisy, add `--cwd`, `--model`, `--from`, or repeated `--platform` filters. 3. Run `get <session-id>` on likely hits. The result includes a `subagents` array: every child session (Claude Task/workflow agents, Codex thread spawns, OpenCode child sessions) with its own id, `agent` label, and raw path — follow up with `get <child-id>` for a child's events. 4. Open raw `path` files for exact quotes, tool calls, and evidence. ## Subagent linkage cheat sheet | Platform | Child identity | Parent linkage | Agent label | |---|---|---|---| | Claude | `projects/<proj>/<sid>/subagents/**/agent-<agentId>.jsonl` | directory `<sid>` | `agent-*.meta.json` `agentType` | | Codex | thread row + own rollout JSONL | `thread_spawn_edges` / `source.subagent.thread_spawn.parent_thread_id` | `agent_nickname (agent_role)` | | OpenCode | `session` table row / `storage/session/**.json` | `parent_id` column / `parentID` field | `agent` column | | Aside | own `sessions/<date>_<id>/messages.jsonl` transcript | `parent_id` column in `state.db` `sessions` | child session's `title` column (task description) | ## Parallelism The finder scans selected platforms concurrently, parses transcript files concurrently, joins OpenCode message/part files concurrently, and evaluates repeated `--query` values concurrently. Increase `--workers` for large local stores: ```bash python3 scripts/find-agent-sessions.py search --query "commit" --query "deploy" --workers 64 python3 scripts/find-agent-sessions.py search --query "commit" --platform senpi --platform opencode --workers 64 ``` Omit `--platform` for the full multi-platform search. Add repeated platform flags such as `--platform openclaw --platform droid` only when the user already knows the likely stores. Comma-separated platform values are intentionally unsupported. The default scan is intentionally probe-first: optional platforms check for exact store roots before globbing or opening files. This avoids expensive home-wide searches while still making newly installed local agents visible without changing the command. OpenCode is optimized differently from raw JSONL stores: it calls `opencode db path`, reads the SQLite session table directly, falls back to `opencode session list --format json`, and only scans `messages/` / `parts/` when the DB/CLI is unavailable or explicit `--root` values force a file-store lookup. ## Evidence rule Never answer "what happened in a session" from the normalized preview alone. Use the preview to locate candidates, then inspect the raw transcript or `get` output. -
claude.md 2.3 KB
# Claude Session Stores Observed Claude formats: - `~/.claude/projects/<encoded-cwd>/*.jsonl`: Claude Code project sessions (main sessions; filename = session ID). - `~/.claude/transcripts/ses_*.jsonl`: compact transcript exports with simple `type`, `timestamp`, `content`, and tool fields. - `~/.claude/pre-compact-session-histories/*.jsonl`: pre-compaction histories with `sessionId`, `cwd`, `message`, `uuid`, `parentUuid`, and tool result metadata. For project files, `sessionId`, `cwd`, `version`, `gitBranch`, and `message.model` are usually embedded in each line. For transcript exports, the filename may be the only stable session ID. When reconstructing a Claude session, preserve the event order from the JSONL file and distinguish assistant text, thinking, tool use, tool result, and progress events. ## Subagent transcripts (per-session directory) Each main session may own a sibling directory named after the session ID: ``` projects/<encoded-cwd>/<session-id>/ ├── subagents/ │ ├── agent-<agentId>.jsonl # Task-tool subagent transcript │ ├── agent-<agentId>.meta.json # {"agentType", "description", "toolUseId"} │ └── workflows/wf_<id>/ │ ├── agent-<agentId>.jsonl # Workflow-spawned agent transcripts │ ├── agent-<agentId>.meta.json │ └── journal.jsonl # workflow orchestration journal — NOT a session ├── workflows/wf_<id>.json # workflow run metadata └── tool-results/*.txt # persisted oversized tool outputs ``` Subagent JSONL lines look like main-session lines but carry `isSidechain: true`, `agentId`, `promptId`, and `sessionId` pointing at the PARENT session — never treat a subagent file's `sessionId` as its own identity; use the `agentId` (also in the filename) and take the parent from the directory path. The first line's user message is the delegated task prompt; `agent-*.meta.json` gives the human-readable `description` and `agentType`. The finder models these as `platform: claude` sessions with `id = <agentId>`, `parent_id = <session-id>`, `agent = agentType`. `get <main-session-id>` lists them under `subagents`; `get <agentId>` returns the child transcript events. Older Claude Code versions inlined sidechains in the main JSONL (`isSidechain: true` lines) instead of separate files. -
codex.md 1.8 KB
# Codex Sessions Codex has two useful surfaces: - `$CODEX_HOME/state_*.sqlite` stores thread metadata. - `$CODEX_HOME/sessions/**/rollout-*.jsonl` and archived rollout files store event transcripts. Use the broad finder for Codex discovery: ```bash python3 scripts/find-agent-sessions.py list --platform codex --limit 10 python3 scripts/find-agent-sessions.py search "deploy" --platform codex python3 scripts/find-agent-sessions.py search --query "deploy" --query "token usage" --platform codex --workers 32 python3 scripts/find-agent-sessions.py get <session-id> --platform codex ``` Important filters: `--from`, `--to`, `--cwd`, `--model`, `--root`, `--limit`, and `--include-subagents`. ## Spawned (subagent) threads Codex subagent threads are ordinary rows in `threads` with their own rollout files. Two linkage sources: - `thread_spawn_edges(parent_thread_id, child_thread_id, status)` — authoritative parent→child table. - `threads.source` — `cli` / `exec` / `vscode` for user-started threads, or JSON for spawned ones: - `{"subagent": "review"}`, `{"subagent": "memory_consolidation"}` — built-in side threads. - `{"subagent": {"thread_spawn": {"parent_thread_id": "...", "depth": 1, "agent_nickname": "Tesla", "agent_role": "explorer"}}}` — collab/multi-agent spawns (depth can exceed 1: children spawn grandchildren). `threads` also carries `agent_nickname`, `agent_role`, `model`, `first_user_message`, `tokens_used`. Without the SQLite DB, the same linkage is in each rollout file's first line: `type: "session_meta"` whose `payload` has `id`, `cwd`, `model_provider`, `forked_from_id`, and the same `source.subagent.thread_spawn` object. The finder maps these to `parent_id` and `agent = "nickname (role)"`; `get <parent-thread-id>` lists children under `subagents`, and `get <child-thread-id>` returns the child's rollout events. -
opencode.md 2.5 KB
# OpenCode Session Stores ## Fast path Prefer the OpenCode CLI index before touching session files: ```bash opencode db path sqlite3 "$(opencode db path)" 'select id, title, directory, time_updated from session order by time_updated desc limit 2000' opencode session list --format json --max-count 100 ``` Use direct SQLite via `opencode db path` for ordinary `list` and title/session-ID/cwd searches. It queries OpenCode's own indexed session database and avoids expensive scans across `messages/` and `parts/`. Use `opencode session list --format json` as the next fallback when direct DB access is unavailable. Only fall back to file joins when `opencode` is not installed, the command fails, or the user passes an explicit `--root` for a nonstandard store. ## Subagent (child) sessions Child sessions spawned by the task tool / subagents are ordinary rows in the `session` table: - `session.parent_id` — parent session ID (`NULL` for main sessions). Tens of thousands of child rows are normal. - `session.agent` — subagent name (`explore`, `plan`, `librarian`, `Sisyphus-Junior`, ...). - Child titles often end with the convention `"... (@<agent> subagent)"`. ```bash sqlite3 "$(opencode db path)" "select id, agent, title from session where parent_id='<parent-id>' order by time_created" ``` In the file store, the same linkage is the `parentID` field of `storage/session/<project-hash>/ses_*.json` info files. The finder surfaces children with `parent_id` + `agent` set; `get <parent-id>` lists them under `subagents`. ## File fallback Observed OpenCode layouts vary by version: - `~/.opencode/messages/ses_*/*.json`: message metadata. - `~/.opencode/parts/msg_*/*.json`: message parts, including text and synthetic compaction context. - `~/.opencode/sessions/**`: newer session indexes or compatibility storage. - `~/.local/share/opencode/storage/session/<project-hash>/ses_*.json`: session info files (`id`, `parentID`, `title`, `directory`, `time.created/updated`) — sibling `storage/message/` and `storage/part/` (singular) dirs hold content. - `~/.local/share/opencode/storage/**`: older or Linux/XDG storage, often with session/message JSON or SQLite-backed data. Message metadata usually contains `sessionID`, `role`, `time.created`, `agent`, `model.providerID`, `model.modelID`, and `path.cwd`. Text content may live in `parts/<message-id>/*.json`. When answering from OpenCode, join messages to parts by `messageID` and order by `time.created` or part time. Compaction injections are useful for continuity but should be labeled as synthetic. -
senpi.md 1.8 KB
# OMO / Senpi / pi Family Coding-Agent Sessions The pi family (OMO/Senpi, oh-my-pi, gajae-code) shares one session format, so one scanner serves all three under separate platform keys. | Platform key | Aliases | Config root | Sessions | |---|---|---|---| | `senpi` | - | `~/.omo`, `~/.senpi`, `~/.pi` | `<root>/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl` | | `oh-my-pi` | `omp`, `ohmypi` | `~/.omp` | `~/.omp/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl` | | `gajae-code` | `gjc`, `gajae` | `~/.gjc` | `~/.gjc/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl` | Extra roots scanned for every pi-family platform: - Named profiles: `<config-root>/profiles/<profile>/agent/sessions/**`. - XDG stores (macOS/Linux, default profile): `$XDG_DATA_HOME/<app>/sessions/**` and `$XDG_DATA_HOME/<app>/profiles/<profile>/sessions/**`, where `<app>` is `senpi`, `omp`, or `gjc`. XDG flattens the `agent/` path segment. - A custom `PI_CONFIG_DIR` / `PI_CODING_AGENT_DIR` / `GJC_CONFIG_DIR` store: pass that agent directory with `--root`. `~/.omo/agent` and `~/.senpi/agent` are the current and legacy OMO/Senpi stores. Their `settings.json`, `models.json`, and `auth.json` files provide environment context; oh-my-pi and gajae-code keep the same files plus `config.yml` and `models.yml` next to their sessions directory. Common event types: - `session`: includes `id`, `timestamp`, and `cwd`. - `model_change`: includes `provider` and `modelId`. - `thinking_level_change`: includes effort metadata. - `message`: wraps provider-native messages, tool calls, usage, and costs. - `custom` with `customType: senpi.todo-state`: stores todo state. Prefer Senpi usage fields when present: `usage.input`, `usage.output`, `usage.cacheRead`, `usage.cacheWrite`, `usage.totalTokens`, and `usage.cost.total`.
-
-
scripts
-
agent_sessions
-
aside_scanner.py 4.8 KB
from __future__ import annotations import sqlite3 from dataclasses import dataclass from pathlib import Path from typing import TypeAlias from .jsonio import as_map, iter_jsonl, parse_json_text, text from .timeparse import file_time, unix_millis, unix_seconds from .transcript import content_text, existing, flat_parallel, merge_usage, recent from .types import JsonMap, Session SqlValue: TypeAlias = str | int | float | bytes | None SqlRow: TypeAlias = tuple[SqlValue, ...] @dataclass(frozen=True, slots=True) class _StateRow: parent_id: str | None title: str | None cwd: str | None provider: str | None model: str | None created_at: str | None updated_at: str | None def scan_aside(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".aside"], extra_roots, (".aside",)) sessions: list[Session] = [] for user_dir in _user_dirs(roots): index = _state_index(user_dir / "state.db") paths = list((user_dir / "sessions").glob("*/messages.jsonl")) sessions.extend(flat_parallel(recent(paths), workers, lambda path, rows=index: [_aside_session(path, rows)])) return sessions def _user_dirs(roots: list[Path]) -> list[Path]: dirs: list[Path] = [] for root in roots: users = sorted(path for path in (root / "u").glob("*") if path.is_dir()) if users: dirs.extend(users) elif (root / "sessions").exists(): dirs.append(root) return dirs def _state_index(db_path: Path) -> dict[str, _StateRow]: if not db_path.exists(): return {} try: with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn: rows: list[SqlRow] = conn.execute("select id, parent_id, title, cwd, model, created_at, updated_at from sessions").fetchall() except sqlite3.Error: return {} index: dict[str, _StateRow] = {} for row in rows: sid = _cell_text(row, 0) if sid is None: continue provider, model = _model_fields(_cell_text(row, 4)) index[sid] = _StateRow( _cell_text(row, 1), _cell_text(row, 2), _cell_text(row, 3), provider, model, unix_seconds(_cell_number(row, 5)), unix_seconds(_cell_number(row, 6)), ) return index def _cell_text(row: SqlRow, index: int) -> str | None: value = row[index] if index < len(row) else None return value if isinstance(value, str) else None def _cell_number(row: SqlRow, index: int) -> int | float | None: value = row[index] if index < len(row) else None return value if isinstance(value, int | float) else None def _model_fields(model_json: str | None) -> tuple[str | None, str | None]: data = as_map(parse_json_text(model_json)) if model_json else None if data is None: return None, None return text(data.get("provider")), text(data.get("modelId")) or text(data.get("model")) def _aside_session(path: Path, index: dict[str, _StateRow]) -> Session: sid = _dir_session_id(path.parent.name) row = index.get(sid) first_user = last_user = "" provider = model = None created = updated = None usage: JsonMap = {} for data in iter_jsonl(path): stamp = data.get("timestamp") moment = unix_millis(int(stamp)) if isinstance(stamp, int | float) else None created = created or moment updated = moment or updated role = text(data.get("role")) if role == "user": prompt = content_text(data.get("content")) if prompt: first_user = first_user or prompt last_user = prompt elif role == "assistant": provider = provider or text(data.get("provider")) model = model or text(data.get("model")) merge_usage(usage, as_map(data.get("usage"))) return Session( "aside", sid, str(path), row.cwd if row is not None else None, (row.created_at if row is not None else None) or created or file_time(path), (row.updated_at if row is not None else None) or updated or created or file_time(path), (row.provider if row is not None else None) or provider, (row.model if row is not None else None) or model, first_user, usage, row.parent_id if row is not None else None, row.title if row is not None and row.parent_id is not None else None, last_user, ) def _dir_session_id(name: str) -> str: return name.split("_", 1)[-1] def _roots(defaults: list[Path], extra_roots: tuple[Path, ...], children: tuple[str, ...]) -> list[Path]: candidates = [*defaults] for root in extra_roots: candidates.append(root) candidates.extend(root / child for child in children) return existing(candidates) -
claude.py 2.4 KB
from __future__ import annotations from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from .jsonio import as_map, iter_jsonl, read_json, text from .timeparse import file_time from .transcript import content_text, env_path, existing, jsonl_parallel, recent from .types import Session def scan_claude(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: appdata = env_path("APPDATA") roots = existing([Path.home() / ".claude", *(path / "Claude" for path in (appdata,) if path is not None), *extra_roots]) mains: list[Path] = [] subagents: list[Path] = [] for root in roots: mains.extend((root / "transcripts").glob("*.jsonl")) mains.extend((root / "projects").glob("*/*.jsonl")) mains.extend((root / "pre-compact-session-histories").glob("*.jsonl")) subagents.extend((root / "projects").glob("*/*/subagents/**/agent-*.jsonl")) sessions = jsonl_parallel(recent(mains), workers, "claude", lambda path: path.stem) sessions.extend(_subagent_parallel(recent(subagents), workers)) return sessions def _subagent_parallel(paths: list[Path], workers: int) -> list[Session]: if not paths: return [] sessions: list[Session] = [] with ThreadPoolExecutor(max_workers=min(workers, max(len(paths), 1))) as pool: futures = [pool.submit(_subagent_session, path) for path in paths] for future in as_completed(futures): session = future.result() if session is not None: sessions.append(session) return sessions def _subagent_session(path: Path) -> Session | None: parts = path.parts if "subagents" not in parts: return None parent_sid = parts[parts.index("subagents") - 1] meta = as_map(read_json(path.with_name(path.stem + ".meta.json"))) or {} first = next(iter_jsonl(path), None) or {} message = as_map(first.get("message")) or {} description = text(meta.get("description")) or "" task = content_text(message.get("content")) prompt = "\n".join(part for part in (description, task) if part) created = text(first.get("timestamp")) or file_time(path) return Session( "claude", path.stem.removeprefix("agent-"), str(path), text(first.get("cwd")), created, file_time(path) or created, None, None, prompt, {}, parent_sid, text(meta.get("agentType")), prompt, ) -
cli.py 10.6 KB
from __future__ import annotations import os from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from .jsonio import as_map, dumps, iter_jsonl from .scanners import DEFAULT_PLATFORMS, scan from .timeparse import date_bound, parse_stamp from .transcript import user_text from .types import Json, JsonMap, Options, Session def main() -> int: command, opts, rest = _parse() sessions = sorted(scan(opts.platforms, opts.roots, opts.workers), key=lambda item: item.created_at or "", reverse=True) if command == "list": _emit(_list_payload(_filter(sessions, opts), sessions, opts.limit, include_subagents=opts.include_subagents)) return 0 if command == "search": queries = _queries(opts, rest) _require(list(queries), "search requires a query") _emit(_search_payload(_filter(sessions, opts), sessions, queries, opts.limit, opts.workers, include_subagents=opts.include_subagents)) return 0 if command == "get": _require(rest, "read requires at least one session id") _emit(_get_payload(sessions, rest)) return 0 raise SystemExit(f"unknown command: {command}") def _parse() -> tuple[str, Options, list[str]]: import sys args = sys.argv[1:] if not args or args[0] in {"-h", "--help"}: usage = ( "Usage: find-agent-sessions.py list|find|search|read|get [query|ids...] [--query TEXT ...] " "[--platform NAME ...] [--root PATH] [--from DATE] [--to DATE] [--cwd TEXT] " "[--model TEXT] [--limit N] [--workers N] [--include-subagents]" ) print(usage) raise SystemExit(0) command = args.pop(0) if command == "find": command = "search" elif command == "read": command = "get" roots: list[Path] = [] queries: list[str] = [] platforms: list[str] = [] date_from = date_to = cwd = model = None limit = 20 workers = _default_workers() include_subagents = False rest: list[str] = [] index = 0 while index < len(args): arg = args[index] if arg == "--root": roots.append(Path(args[index + 1]).expanduser()) index += 2 elif arg == "--query": queries.append(args[index + 1]) index += 2 elif arg == "--platform": platform = args[index + 1].strip().lower() if "," in platform: raise SystemExit("Use repeated --platform flags, for example: --platform senpi --platform opencode") platforms.append(platform) index += 2 elif arg == "--from": date_from = args[index + 1] index += 2 elif arg == "--to": date_to = args[index + 1] index += 2 elif arg == "--cwd": cwd = args[index + 1].lower() index += 2 elif arg == "--model": model = args[index + 1].lower() index += 2 elif arg == "--limit": limit = int(args[index + 1]) index += 2 elif arg == "--workers": workers = max(int(args[index + 1]), 1) index += 2 elif arg == "--include-subagents": include_subagents = True index += 1 else: rest.append(arg) index += 1 selected_platforms = frozenset(platforms) if platforms else DEFAULT_PLATFORMS return command, Options(selected_platforms, tuple(roots), tuple(queries), date_from, date_to, cwd, model, limit, workers, include_subagents), rest def _filter(sessions: list[Session], opts: Options) -> list[Session]: start = date_bound(opts.date_from) end = date_bound(opts.date_to, end=True) result: list[Session] = [] for item in sessions: stamp = parse_stamp(item.created_at) if start is not None and stamp is not None and stamp < start: continue if end is not None and stamp is not None and stamp >= end: continue if opts.cwd is not None and opts.cwd not in (item.cwd or "").lower(): continue if opts.model is not None and opts.model not in (item.model or "").lower(): continue result.append(item) return result def _child_counts(sessions: list[Session]) -> dict[tuple[str, str], int]: counts: dict[tuple[str, str], int] = {} for item in sessions: if item.parent_id is not None: key = (item.platform, item.parent_id) counts[key] = counts.get(key, 0) + 1 return counts def _annotate(item: Session, counts: dict[tuple[str, str], int]) -> JsonMap: data = item.to_json() data["subagent_count"] = counts.get((item.platform, item.id), 0) data["detail_hint"] = _detail_hint(item) return data def _list_payload(filtered: list[Session], all_sessions: list[Session], limit: int, include_subagents: bool = False) -> JsonMap: counts = _child_counts(all_sessions) candidates = filtered if include_subagents else [item for item in filtered if item.parent_id is None] results: list[Json] = [_annotate(item, counts) for item in candidates[:limit]] return {"count": len(results), "results": results} def _search_payload(filtered: list[Session], all_sessions: list[Session], queries: tuple[str, ...], limit: int, workers: int, include_subagents: bool = False) -> JsonMap: counts = _child_counts(all_sessions) candidates = filtered if include_subagents else [item for item in filtered if item.parent_id is None] per_query: list[Json] = [] merged: dict[tuple[str, str], tuple[Session, list[JsonMap]]] = {} with ThreadPoolExecutor(max_workers=min(workers, max(len(queries), 1))) as pool: futures = [pool.submit(_search_one, candidates, query, limit) for query in queries] for future in as_completed(futures): query, matches = future.result() per_query.append({"query": query, "count": len(matches), "results": [_annotate_search(item, counts, reasons) for item, reasons in matches]}) for item, reasons in matches: key = (item.platform, item.id) if key not in merged: merged[key] = (item, reasons) results: list[Json] = [_annotate_search(item, counts, reasons) for item, reasons in list(merged.values())[:limit]] return {"count": len(results), "queries": per_query, "results": results} def _get_payload(sessions: list[Session], ids: list[str]) -> JsonMap: counts = _child_counts(sessions) results: list[Json] = [] for item in sessions: if item.id not in ids and not any(item.id.startswith(prefix) for prefix in ids): continue events = _events(item) first_prompt, last_prompt = _prompt_edges(item, events) session = _annotate(item, counts) session["first_user_message"] = first_prompt[:300] session["last_user_message"] = last_prompt[:300] children = sorted( (child for child in sessions if child.platform == item.platform and child.parent_id == item.id), key=lambda child: child.created_at or "", ) results.append( { "session": session, "prompts": {"first_user_message": first_prompt, "last_user_message": last_prompt}, "events": events, "subagents": [_annotate(child, counts) for child in children], "detail_hint": _detail_hint(item), } ) return {"count": len(results), "results": results} def _annotate_search(item: Session, counts: dict[tuple[str, str], int], reasons: list[JsonMap]) -> JsonMap: data = _annotate(item, counts) reason_rows: list[Json] = [] reason_rows.extend(reasons) data["match_reasons"] = reason_rows return data def _detail_hint(item: Session) -> str: return f"python3 scripts/find-agent-sessions.py read {item.id} --platform {item.platform}" def _events(item: Session) -> list[Json]: if item.path.endswith(".jsonl"): return list(iter_jsonl(Path(item.path))) events: list[Json] = [] if item.first_user_message: events.append({"type": "message", "message": {"role": "user", "content": item.first_user_message}}) if item.last_user_message and item.last_user_message != item.first_user_message: events.append({"type": "message", "message": {"role": "user", "content": item.last_user_message}}) return events def _prompt_edges(item: Session, events: list[Json]) -> tuple[str, str]: first_prompt = item.first_user_message last_prompt = item.last_user_message or item.first_user_message for event in events: if not isinstance(event, dict): continue message = as_map(event.get("message")) or as_map(event.get("payload")) or {} prompt = user_text(event, message) if prompt: first_prompt = first_prompt or prompt last_prompt = prompt return first_prompt, last_prompt def _match_reasons(item: Session, query: str) -> list[JsonMap]: needle = query.lower() reasons: list[JsonMap] = [] for field, value in _search_fields(item): if needle in value.lower(): reasons.append({"query": query, "platform": item.platform, "field": field, "snippet": _snippet(value, needle)}) return reasons def _search_fields(item: Session) -> tuple[tuple[str, str], ...]: return ( ("platform", item.platform), ("id", item.id), ("path", item.path), ("cwd", item.cwd or ""), ("provider", item.provider or ""), ("model", item.model or ""), ("agent", item.agent or ""), ("first_user_message", item.first_user_message), ("last_user_message", item.last_user_message), ) def _snippet(value: str, needle: str) -> str: start = max(value.lower().find(needle) - 60, 0) end = min(start + 160, len(value)) return value[start:end] def _queries(opts: Options, rest: list[str]) -> tuple[str, ...]: if opts.queries: return opts.queries joined = " ".join(rest).strip() return (joined,) if joined else () def _search_one(sessions: list[Session], query: str, limit: int) -> tuple[str, list[tuple[Session, list[JsonMap]]]]: matches: list[tuple[Session, list[JsonMap]]] = [] for item in sessions: reasons = _match_reasons(item, query) if reasons: matches.append((item, reasons)) return query, matches[:limit] def _emit(value: JsonMap) -> None: print(dumps(value)) def _require(values: list[str], message: str) -> None: if not values: raise SystemExit(message) def _default_workers() -> int: cpu = os.cpu_count() or 4 return min(max(cpu * 4, 8), 64) if __name__ == "__main__": raise SystemExit(main()) -
codex.py 3.8 KB
from __future__ import annotations import sqlite3 from pathlib import Path from typing import TypeAlias from .timeparse import unix_seconds from .transcript import env_path, existing, flat_parallel, jsonl_parallel, nick_role, recent, spawn_info, stem_id from .types import Json, Session THREADS_SQL = ( "SELECT id, rollout_path, cwd, created_at, updated_at, model_provider, model, first_user_message, tokens_used, " "source, agent_nickname, agent_role FROM threads" ) LEGACY_THREADS_SQL = ( "SELECT id, rollout_path, cwd, created_at, updated_at, model_provider, model, first_user_message, tokens_used FROM threads" ) SPAWN_EDGES_SQL = "SELECT child_thread_id, parent_thread_id FROM thread_spawn_edges" SqliteScalar: TypeAlias = str | int | float | bytes | None CodexRow: TypeAlias = tuple[SqliteScalar, ...] def scan_codex(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = existing([*(path for path in (env_path("CODEX_HOME"),) if path is not None), Path.home() / ".codex", *extra_roots]) db_paths: list[Path] = [] rollouts: list[Path] = [] for root in roots: db_paths.extend(root.glob("state_*.sqlite")) rollouts.extend((root / "sessions").rglob("rollout-*.jsonl")) rollouts.extend((root / "archived_sessions").glob("rollout-*.jsonl")) sessions = flat_parallel(db_paths, workers, _codex_db) sessions.extend(jsonl_parallel(recent(rollouts), workers, "codex", lambda path: stem_id(path, "rollout-"))) return sessions def _codex_db(path: Path) -> list[Session]: conn: sqlite3.Connection | None = None try: conn = sqlite3.connect(str(path), timeout=3) edges = _spawn_edges(conn) rows = _thread_rows(conn) except sqlite3.Error: return [] finally: if conn is not None: conn.close() return [_codex_row(path, row, edges) for row in rows] def _thread_rows(conn: sqlite3.Connection) -> list[CodexRow]: try: primary_rows: list[CodexRow] = conn.execute(THREADS_SQL).fetchall() return primary_rows except sqlite3.Error: legacy_rows: list[CodexRow] = conn.execute(LEGACY_THREADS_SQL).fetchall() return legacy_rows def _spawn_edges(conn: sqlite3.Connection) -> dict[str, str]: try: rows: list[CodexRow] = conn.execute(SPAWN_EDGES_SQL).fetchall() except sqlite3.Error: return {} edges: dict[str, str] = {} for row in rows: child = _row_text(_row_value(row, 0)) parent = _row_text(_row_value(row, 1)) if child is not None and parent is not None: edges[child] = parent return edges def _codex_row(path: Path, row: CodexRow, edges: dict[str, str]) -> Session: thread_id = str(_row_value(row, 0)) source_parent, source_agent = spawn_info(_row_text(_row_value(row, 9))) return Session( "codex", thread_id, str(_row_value(row, 1) or path), _row_text(_row_value(row, 2)), unix_seconds(_row_number(_row_value(row, 3))), unix_seconds(_row_number(_row_value(row, 4))), _row_text(_row_value(row, 5)), _row_text(_row_value(row, 6)), _row_text(_row_value(row, 7)) or "", {"total_tokens": _row_number(_row_value(row, 8))}, edges.get(thread_id) or source_parent, nick_role(_row_text(_row_value(row, 10)), _row_text(_row_value(row, 11))) or source_agent, ) def _row_text(value: Json | int | float | None) -> str | None: return value if isinstance(value, str) else None def _row_number(value: Json | int | float | None) -> int | float | None: return value if isinstance(value, int | float) else None def _row_value(row: CodexRow, index: int) -> Json | int | float | None: value = row[index] if index < len(row) else None if value is None or isinstance(value, str | int | float | bool): return value return None -
file_scanners.py 11.9 KB
from __future__ import annotations from pathlib import Path from urllib.parse import unquote, urlparse from .jsonio import as_map, int_value, iter_jsonl, parse_json_text, read_json, text from .timeparse import file_time, unix_millis, unix_seconds from .transcript import content_text, env_path, existing, flat_parallel, jsonl_parallel, recent from .types import Json, JsonMap, Session def scan_openclaw(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".openclaw"], extra_roots, (".openclaw",)) paths: list[Path] = [] for root in roots: paths.extend((root / "agents").glob("*/sessions/*.jsonl")) paths.extend((root / "sessions").glob("*.jsonl")) paths.extend((root / "session-backups").glob("*/*.jsonl")) paths.extend((root / "session-backups").glob("*/sessions/*.jsonl")) return jsonl_parallel(recent(paths), workers, "openclaw", lambda path: path.stem) def scan_droid(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".factory"], extra_roots, (".factory",)) paths = [path for root in roots for path in (root / "sessions").glob("*/*.jsonl")] return flat_parallel(recent(paths), workers, lambda path: [_droid_session(path)]) def scan_amp(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".local" / "share" / "amp"], extra_roots, ("amp", ".local/share/amp")) paths = [path for root in roots for path in (root / "threads").glob("T-*.json")] return flat_parallel(recent(paths), workers, _amp_sessions) def scan_gemini(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".gemini"], extra_roots, (".gemini",)) paths = [path for root in roots for path in (root / "tmp").glob("*/chats/*.json")] return flat_parallel(recent(paths), workers, _gemini_sessions) def scan_kimi(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".kimi"], extra_roots, (".kimi",)) paths = [path for root in roots for path in (root / "sessions").glob("*/*/wire.jsonl")] return flat_parallel(recent(paths), workers, lambda path: [_kimi_session(path)]) def scan_qwen(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".qwen"], extra_roots, (".qwen",)) paths = [path for root in roots for path in (root / "projects").glob("*/chats/*.jsonl")] return jsonl_parallel(recent(paths), workers, "qwen", lambda path: path.stem) def scan_codebuff(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: defaults = [Path.home() / ".config" / name for name in ("manicode", "manicode-dev", "manicode-staging", "codebuff")] roots = _roots(defaults, extra_roots, ("manicode", "codebuff")) paths = [path for root in roots for path in (root / "projects").glob("*/chats/*/chat-messages.json")] return flat_parallel(recent(paths), workers, _codebuff_sessions) def scan_roocode(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: return _scan_task_dirs("roo-code", "rooveterinaryinc.roo-cline", extra_roots, workers) def scan_kilocode(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: return _scan_task_dirs("kilo-code", "kilocode.kilo-code", extra_roots, workers) def scan_cline(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: return _scan_task_dirs("cline", "saoudrizwan.claude-dev", extra_roots, workers) def scan_aider(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / "local-workspaces", Path.home() / "indent"], extra_roots, ("local-workspaces",)) paths: list[Path] = [] for root in roots: paths.extend(_bounded_named(root, ".aider.chat.history.md")) return flat_parallel(recent(paths), workers, _aider_sessions) def _droid_session(path: Path) -> Session: sid = path.stem cwd = model = first_user = last_user = None created = updated = None settings = as_map(read_json(path.with_suffix(".settings.json"))) or {} for data in iter_jsonl(path): created = created or text(data.get("timestamp")) updated = text(data.get("timestamp")) or updated if data.get("type") == "session_start": sid = text(data.get("id")) or sid cwd = cwd or text(data.get("cwd")) message = as_map(data.get("message")) or {} if message.get("role") == "user": prompt = _without_system_reminders(message.get("content")) if prompt: first_user = first_user or prompt last_user = prompt return Session("droid", sid, str(path), cwd, created or file_time(path), updated or created or file_time(path), None, text(settings.get("model")) or model, first_user or "", _usage(settings), last_user_message=last_user or "") def _amp_sessions(path: Path) -> list[Session]: data = as_map(read_json(path)) if data is None: return [] messages = _json_maps(data.get("messages")) first_user, last_user = _message_edges(messages) env = as_map(data.get("env")) or {} initial = as_map(env.get("initial")) or {} trees = initial.get("trees") tree = as_map(trees[0]) if isinstance(trees, list) and trees else None created = int_value(data.get("created")) return [ Session( "amp", text(data.get("id")) or path.stem, str(path), _file_uri_path(text(tree.get("uri")) if tree is not None else None), unix_millis(created) or file_time(path), file_time(path), None, _tag_model(initial.get("tags")), first_user, {"message_count": len(messages)}, last_user_message=last_user, ) ] def _gemini_sessions(path: Path) -> list[Session]: data = as_map(read_json(path)) if data is None: return [] messages = _json_maps(data.get("messages")) first_user, last_user = _message_edges(messages, role_keys=("user",)) return [Session("gemini", text(data.get("sessionId")) or path.stem, str(path), text(data.get("projectHash")), text(data.get("startTime")) or file_time(path), text(data.get("lastUpdated")) or file_time(path), "google", None, first_user, {"message_count": len(messages)}, last_user_message=last_user)] def _kimi_session(path: Path) -> Session: first_user = last_user = "" created = updated = None for data in iter_jsonl(path): stamp = data.get("timestamp") created = created or unix_seconds(stamp if isinstance(stamp, int | float) else None) updated = unix_seconds(stamp if isinstance(stamp, int | float) else None) or updated payload = as_map(data.get("payload")) or {} prompt = text(payload.get("user_input")) if data.get("type") == "TurnBegin" else None if prompt: first_user = first_user or prompt last_user = prompt return Session("kimi", path.parent.name, str(path), None, created or file_time(path), updated or file_time(path), "moonshot", None, first_user, {}, last_user_message=last_user) def _codebuff_sessions(path: Path) -> list[Session]: data = read_json(path) rows = _json_maps(data) first_user, last_user = _message_edges(rows, role_keys=("user", "human")) return [Session("codebuff", path.parent.name, str(path), None, file_time(path), file_time(path), None, None, first_user, {"message_count": len(rows)}, last_user_message=last_user)] if rows else [] def _scan_task_dirs(platform: str, extension: str, extra_roots: tuple[Path, ...], workers: int) -> list[Session]: appdata = env_path("APPDATA") defaults = [ Path.home() / "Library" / "Application Support" / "Code" / "User" / "globalStorage" / extension, Path.home() / ".config" / "Code" / "User" / "globalStorage" / extension, *(path / "Code" / "User" / "globalStorage" / extension for path in (appdata,) if path is not None), ] roots = _roots(defaults, extra_roots, (extension,)) paths = [path for root in roots for path in (root / "tasks").glob("*/api_conversation_history.json")] return flat_parallel(recent(paths), workers, lambda path: _task_sessions(platform, path)) def _task_sessions(platform: str, path: Path) -> list[Session]: data = read_json(path) rows = _json_maps(data) first_user, last_user = _message_edges(rows, role_keys=("user",)) return [Session(platform, path.parent.name, str(path), None, file_time(path), file_time(path), None, None, first_user, {"message_count": len(rows)}, last_user_message=last_user)] if rows else [] def _aider_sessions(path: Path) -> list[Session]: text_value = path.read_text(encoding="utf-8", errors="replace") sessions: list[Session] = [] for block in text_value.split("# aider chat started at "): if not block.strip(): continue first_line, _, body = block.partition("\n") prompt = _first_aider_prompt(body) stamp = first_line.strip() sid = f"{path.parent.name}-{stamp.replace(':', '-').replace(' ', '-')}" sessions.append(Session("aider", sid, str(path), str(path.parent), stamp, file_time(path), None, None, prompt, {}, last_user_message=prompt)) return sessions def _message_edges(messages: list[JsonMap], role_keys: tuple[str, ...] = ("user",)) -> tuple[str, str]: first_user = "" last_user = "" for message in messages: if text(message.get("role")) not in role_keys and text(message.get("type")) not in role_keys: continue prompt = _content(message.get("content")) if prompt: first_user = first_user or prompt last_user = prompt return first_user, last_user def _json_maps(value: Json | None) -> list[JsonMap]: if not isinstance(value, list): return [] return [item for item in value if isinstance(item, dict)] def _content(value: Json | None) -> str: parsed = parse_json_text(value) if isinstance(value, str) else value return content_text(parsed) or (value if isinstance(value, str) else "") def _without_system_reminders(value: Json | None) -> str: if not isinstance(value, list): return _content(value) parts: list[str] = [] for item in value: if not isinstance(item, dict): continue value_text = text(item.get("text")) or text(item.get("content")) or "" if value_text.startswith("<system-reminder>"): continue parts.append(value_text) return "\n".join(part for part in parts if part) def _usage(data: JsonMap) -> JsonMap: usage = as_map(data.get("tokenUsage")) or {} return {key: value for key, value in usage.items() if isinstance(value, int | float)} def _roots(defaults: list[Path], extra_roots: tuple[Path, ...], children: tuple[str, ...]) -> list[Path]: candidates = [*defaults] for root in extra_roots: candidates.append(root) candidates.extend(root / child for child in children) return existing(candidates) def _bounded_named(root: Path, name: str) -> list[Path]: return [path for pattern in (name, f"*/{name}", f"*/*/{name}") for path in root.glob(pattern)] def _file_uri_path(value: str | None) -> str | None: if value is None: return None parsed = urlparse(value) if parsed.scheme != "file": return value path = unquote(parsed.path) if parsed.netloc and parsed.netloc != "localhost": return f"//{parsed.netloc}{path}" if len(path) >= 3 and path[0] == "/" and path[2] == ":" and path[1].isalpha(): return path[1:] return path def _tag_model(value: Json | None) -> str | None: if not isinstance(value, list): return None for item in value: tag = text(item) if tag is not None and tag.startswith("model:"): return tag.removeprefix("model:") return None def _first_aider_prompt(body: str) -> str: for line in body.splitlines(): if line.startswith("#### "): return line.removeprefix("#### ").strip() return "" -
jsonio.py 1.3 KB
from __future__ import annotations import json from collections.abc import Iterator from pathlib import Path from typing import TYPE_CHECKING from .types import Json, JsonMap if TYPE_CHECKING: def _loads(_text: str) -> Json: ... else: def _loads(text_value: str) -> Json: return json.loads(text_value) def as_map(value: Json | None) -> JsonMap | None: return value if isinstance(value, dict) else None def text(value: Json | None) -> str | None: return value if isinstance(value, str) else None def int_value(value: Json | None) -> int | None: return value if isinstance(value, int) else None def parse_json_text(value: str) -> Json | None: try: return _loads(value) except json.JSONDecodeError: return None def read_json(path: Path) -> Json | None: try: return parse_json_text(path.read_text(encoding="utf-8", errors="replace")) except OSError: return None def iter_jsonl(path: Path) -> Iterator[JsonMap]: try: with path.open(encoding="utf-8", errors="replace") as handle: for line in handle: value = parse_json_text(line) if isinstance(value, dict): yield value except OSError: return def dumps(value: JsonMap) -> str: return json.dumps(value, ensure_ascii=False, indent=2) -
kiro_scanner.py 3.1 KB
from __future__ import annotations from pathlib import Path from .jsonio import as_map, iter_jsonl, read_json, text from .timeparse import file_time, unix_seconds from .transcript import existing, flat_parallel, recent from .types import Json, JsonMap, Session def scan_kiro(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".kiro"], extra_roots, (".kiro", "kiro")) paths = [path for root in roots for path in (root / "sessions" / "cli").glob("*.json")] return flat_parallel(recent(paths), workers, _kiro_sessions) def _kiro_sessions(path: Path) -> list[Session]: data = as_map(read_json(path)) if data is None: return [] state = as_map(data.get("session_state")) or {} rts_state = as_map(state.get("rts_model_state")) or {} model_info = as_map(rts_state.get("model_info")) or {} metadata = as_map(state.get("conversation_metadata")) or {} turns = metadata.get("user_turn_metadatas") first_user, last_user, created = _kiro_prompt_edges(path.with_suffix(".jsonl")) if not first_user: return [] usage: JsonMap = {"turn_count": len(turns)} if isinstance(turns, list) else {} return [ Session( "kiro", text(data.get("session_id")) or text(data.get("sessionId")) or path.stem, str(path), text(data.get("cwd")), created or file_time(path), file_time(path), "amazon-bedrock", text(model_info.get("model_id")), first_user, usage, last_user_message=last_user, ) ] def _kiro_prompt_edges(path: Path) -> tuple[str, str, str | None]: first_user = last_user = "" created = None for row in iter_jsonl(path): if row.get("kind") != "Prompt": continue data = as_map(row.get("data")) or {} prompt = _kiro_content(data.get("content")) if prompt: first_user = first_user or prompt last_user = prompt meta = as_map(data.get("meta")) or {} stamp = meta.get("timestamp") if created is None and isinstance(stamp, int | float): created = unix_seconds(stamp) return first_user, last_user, created def _kiro_content(value: Json | None) -> str: if not isinstance(value, list): return text(value) or "" parts: list[str] = [] for item in value: part = _kiro_content_part(item) if part: parts.append(part) return "\n".join(parts) def _kiro_content_part(value: Json) -> str: if not isinstance(value, dict): return "" item: JsonMap = value kind = text(item.get("kind")) if kind is not None and kind != "text": return "" return text(item.get("data")) or text(item.get("text")) or text(item.get("content")) or "" def _roots(defaults: list[Path], extra_roots: tuple[Path, ...], children: tuple[str, ...]) -> list[Path]: candidates = [*defaults] for root in extra_roots: candidates.append(root) candidates.extend(root / child for child in children) return existing(candidates) -
opencode.py 9.8 KB
from __future__ import annotations import shutil import sqlite3 import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import TypeAlias from .jsonio import as_map, int_value, parse_json_text, read_json, text from .timeparse import unix_millis from .transcript import env_path, existing from .types import JsonMap, Session MAX_OPENCODE_SESSIONS = 50000 MAX_OPENCODE_CLI_SESSIONS = 100 OPENCODE_TIMEOUT_SECONDS = 8 SESSION_SQL = ( "select id, title, directory, time_created, time_updated, cost, tokens_input, tokens_output, " "tokens_reasoning, tokens_cache_read, tokens_cache_write, model, parent_id, agent " "from session where time_archived is null " f"order by time_updated desc limit {MAX_OPENCODE_SESSIONS}" ) LEGACY_SESSION_SQL = ( "select id, title, directory, time_created, time_updated, cost, tokens_input, tokens_output, " "tokens_reasoning, tokens_cache_read, tokens_cache_write, model " "from session where time_archived is null " "order by time_updated desc limit 2000" ) SqlValue: TypeAlias = str | int | float | bytes | None OpenCodeRow: TypeAlias = tuple[SqlValue, ...] def scan_opencode(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: if not extra_roots: sessions = _db_sessions() if sessions: return sessions sessions = _cli_sessions() if sessions: return sessions appdata = env_path("APPDATA") roots = existing([ *(path for path in (env_path("OPENCODE_HOME"),) if path is not None), Path.home() / ".opencode", Path.home() / ".local" / "share" / "opencode", *(path / "opencode" for path in (appdata,) if path is not None), *extra_roots, ]) message_dirs = [message_dir for root in roots for message_dir in (root / "messages").glob("ses_*")] sessions: list[Session] = [] if message_dirs: with ThreadPoolExecutor(max_workers=min(workers, len(message_dirs))) as pool: futures = [pool.submit(_file_session, message_dir.parents[1], message_dir) for message_dir in message_dirs] for future in as_completed(futures): session = future.result() if session is not None: sessions.append(session) sessions.extend(_storage_sessions(roots)) return sessions def _storage_sessions(roots: list[Path]) -> list[Session]: sessions: list[Session] = [] for root in roots: for path in (root / "storage" / "session").glob("*/ses_*.json"): info = as_map(read_json(path)) if info is None: continue time_info = as_map(info.get("time")) or {} sessions.append( Session( "opencode", text(info.get("id")) or path.stem, str(path), text(info.get("directory")), unix_millis(int_value(time_info.get("created"))), unix_millis(int_value(time_info.get("updated"))), None, None, text(info.get("title")) or "", {}, text(info.get("parentID")), text(info.get("agent")), ) ) return sessions def _db_sessions() -> list[Session]: path = _db_path() if path is None: return [] conn: sqlite3.Connection | None = None try: conn = sqlite3.connect(str(path), timeout=3) rows: list[OpenCodeRow] = _session_rows(conn) except sqlite3.Error: return [] finally: if conn is not None: conn.close() return [_db_session(row) for row in rows] def _session_rows(conn: sqlite3.Connection) -> list[OpenCodeRow]: try: return conn.execute(SESSION_SQL).fetchall() except sqlite3.Error: return conn.execute(LEGACY_SESSION_SQL).fetchall() def _db_path() -> Path | None: data = _opencode_text(["db", "path"]) candidates = [Path(data.strip()).expanduser()] if data else [] appdata = env_path("APPDATA") candidates.extend([Path.home() / ".local" / "share" / "opencode" / "opencode.db"]) candidates.extend(path / "opencode" / "opencode.db" for path in (appdata,) if path is not None) for path in candidates: if str(path) and path.exists(): return path return None def _db_session(row: OpenCodeRow) -> Session: session_id = _row_text(row, 0) or "" title = _row_text(row, 1) or "" model = as_map(parse_json_text(_row_text(row, 11) or "")) return Session( "opencode", session_id, f"opencode://{session_id}", _row_text(row, 2), unix_millis(_row_int(row, 3)), unix_millis(_row_int(row, 4)), text(model.get("providerID")) if model is not None else None, text(model.get("id")) if model is not None else None, title, _db_usage(row), _row_text(row, 12), _row_text(row, 13), ) def _db_usage(row: OpenCodeRow) -> JsonMap: usage: JsonMap = {} for index, key in ((5, "cost_total"), (6, "input"), (7, "output"), (8, "reasoning"), (9, "cacheRead"), (10, "cacheWrite")): value = _row_number(row, index) if value is not None: usage[key] = value return usage def _row_text(row: OpenCodeRow, index: int) -> str | None: value = row[index] if index < len(row) else None return value if isinstance(value, str) else None def _row_number(row: OpenCodeRow, index: int) -> int | float | None: value = row[index] if index < len(row) else None return value if isinstance(value, int | float) else None def _row_int(row: OpenCodeRow, index: int) -> int | None: value = row[index] if index < len(row) else None return value if isinstance(value, int) else None def _cli_sessions() -> list[Session]: data = _opencode_json(["session", "list", "--format", "json", "--max-count", str(MAX_OPENCODE_CLI_SESSIONS)]) if not isinstance(data, list): return [] return [_cli_session(item) for item in data] def _cli_session(item: JsonMap) -> Session: session_id = text(item.get("id")) or "" title = text(item.get("title")) or "" usage = _usage(item) return Session( "opencode", session_id, f"opencode://{session_id}", text(item.get("directory")), unix_millis(int_value(item.get("created"))), unix_millis(int_value(item.get("updated"))), None, None, title, usage, ) def _opencode_json(args: list[str]) -> JsonMap | list[JsonMap] | None: data = _opencode_text(args) return _json_result(data) if data is not None else None def _opencode_text(args: list[str]) -> str | None: binary = shutil.which("opencode") if binary is None: return None try: proc = subprocess.run( [binary, *args], check=False, capture_output=True, text=True, timeout=OPENCODE_TIMEOUT_SECONDS, ) except (OSError, subprocess.TimeoutExpired): return None if proc.returncode != 0: return None return proc.stdout def _json_result(value: str) -> JsonMap | list[JsonMap] | None: data = parse_json_text(value) if isinstance(data, dict): return data if isinstance(data, list): rows = [item for item in data if isinstance(item, dict)] return rows return None def _usage(item: JsonMap) -> JsonMap: usage: JsonMap = {} cost = item.get("cost") tokens = as_map(item.get("tokens")) if isinstance(cost, int | float): usage["cost_total"] = cost if tokens is not None: for key in ("input", "output", "reasoning", "cacheRead", "cacheWrite"): value = tokens.get(key) if isinstance(value, int | float): usage[key] = value return usage def _file_session(root: Path, message_dir: Path) -> Session | None: messages = sorted(message_dir.glob("*.json"), key=lambda item: item.name) first = as_map(read_json(messages[0])) if messages else None if first is None: return None session_id = text(first.get("sessionID")) or message_dir.name model = as_map(first.get("model")) or {} path_info = as_map(first.get("path")) or {} stamps = [_message_millis(path) for path in messages] known = [stamp for stamp in stamps if stamp is not None] first_prompt, last_prompt = _prompt_edges(root, session_id, messages) return Session( "opencode", session_id, str(message_dir), text(path_info.get("cwd")), unix_millis(min(known) if known else None), unix_millis(max(known) if known else None), text(model.get("providerID")), text(model.get("modelID")), first_prompt, {"message_count": len(messages)}, last_user_message=last_prompt, ) def _prompt_edges(root: Path, session_id: str, messages: list[Path]) -> tuple[str, str]: first_prompt = "" last_prompt = "" for path in messages: data = as_map(read_json(path)) if data is None or data.get("role") != "user": continue message_id = text(data.get("id")) for part in (root / "parts" / (message_id or "")).glob("*.json"): part_data = as_map(read_json(part)) if part_data is not None and part_data.get("sessionID") == session_id: value = text(part_data.get("text")) if value: first_prompt = first_prompt or value last_prompt = value return first_prompt, last_prompt def _message_millis(path: Path) -> int | None: data = as_map(read_json(path)) time_data = as_map(data.get("time")) if data is not None else None return int_value(time_data.get("created")) if time_data is not None else None -
pi_family.py 1.8 KB
from __future__ import annotations from pathlib import Path from .transcript import env_path, existing, jsonl_parallel, recent, stem_id from .types import Session SENPI_CONFIG_DIRS = (".omo", ".senpi", ".pi") OH_MY_PI_CONFIG_DIRS = (".omp",) GAJAE_CODE_CONFIG_DIRS = (".gjc",) __all__ = ["scan_gajae_code", "scan_oh_my_pi", "scan_senpi"] def scan_senpi(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: return _scan_pi_family("senpi", SENPI_CONFIG_DIRS, "senpi", extra_roots, workers) def scan_oh_my_pi(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: return _scan_pi_family("oh-my-pi", OH_MY_PI_CONFIG_DIRS, "omp", extra_roots, workers) def scan_gajae_code(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: return _scan_pi_family("gajae-code", GAJAE_CODE_CONFIG_DIRS, "gjc", extra_roots, workers) def _scan_pi_family(platform: str, config_dirs: tuple[str, ...], xdg_app: str, extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _pi_family_roots(config_dirs, xdg_app, extra_roots) paths = [path for root in roots for path in (root / "sessions").rglob("*.jsonl")] return jsonl_parallel(recent(paths), workers, platform, lambda path: stem_id(path, "_")) def _pi_family_roots(config_dirs: tuple[str, ...], xdg_app: str, extra_roots: tuple[Path, ...]) -> list[Path]: home = Path.home() roots: list[Path] = [] for config_dir in config_dirs: base = home / config_dir roots.append(base / "agent") roots.extend(sorted(base.glob("profiles/*/agent"))) xdg_data = env_path("XDG_DATA_HOME") if xdg_data is not None: app_root = xdg_data / xdg_app roots.append(app_root) roots.extend(sorted(app_root.glob("profiles/*"))) roots.extend(extra_roots) return existing(roots) -
scanners.py 3.2 KB
from __future__ import annotations from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import Callable, TypeAlias from .aside_scanner import scan_aside from .claude import scan_claude from .codex import scan_codex from .file_scanners import ( scan_aider, scan_amp, scan_cline, scan_codebuff, scan_droid, scan_gemini, scan_kilocode, scan_kimi, scan_openclaw, scan_qwen, scan_roocode, ) from .kiro_scanner import scan_kiro from .opencode import scan_opencode from .pi_family import scan_gajae_code, scan_oh_my_pi, scan_senpi from .sqlite_optional_scanners import scan_crush, scan_goose, scan_hermes, scan_kilo_cli, scan_zed from .sqlite_scanners import scan_cursor_cli, scan_kodu from .types import Session Scanner: TypeAlias = Callable[[tuple[Path, ...], int], list[Session]] PLATFORM_SCANNERS: dict[str, Scanner] = { "codex": scan_codex, "claude": scan_claude, "senpi": scan_senpi, "oh-my-pi": scan_oh_my_pi, "gajae-code": scan_gajae_code, "opencode": scan_opencode, "openclaw": scan_openclaw, "droid": scan_droid, "amp": scan_amp, "gemini": scan_gemini, "kimi": scan_kimi, "qwen": scan_qwen, "codebuff": scan_codebuff, "roo-code": scan_roocode, "kilo-code": scan_kilocode, "cline": scan_cline, "kodu": scan_kodu, "cursor-cli": scan_cursor_cli, "aider": scan_aider, "kilo-cli": scan_kilo_cli, "hermes": scan_hermes, "goose": scan_goose, "crush": scan_crush, "zed": scan_zed, "kiro": scan_kiro, "aside": scan_aside, } DEFAULT_PLATFORMS = frozenset(PLATFORM_SCANNERS) PLATFORM_ALIASES = { "cursor": "cursor-cli", "factory": "droid", "roo": "roo-code", "roocode": "roo-code", "kilocode": "kilo-code", "kilo": "kilo-cli", "omp": "oh-my-pi", "ohmypi": "oh-my-pi", "oh_my_pi": "oh-my-pi", "gjc": "gajae-code", "gajae": "gajae-code", "gajaecode": "gajae-code", "aside-browser": "aside", } __all__ = ["DEFAULT_PLATFORMS", "PLATFORM_SCANNERS", "scan", "scan_claude", "scan_codex", "scan_gajae_code", "scan_oh_my_pi", "scan_opencode", "scan_senpi"] def scan(platforms: frozenset[str], roots: tuple[Path, ...], workers: int) -> list[Session]: selected = frozenset(PLATFORM_ALIASES.get(platform, platform) for platform in platforms) tasks = [task for platform, task in PLATFORM_SCANNERS.items() if platform in selected] if not tasks: return [] sessions: list[Session] = [] with ThreadPoolExecutor(max_workers=min(workers, max(len(tasks), 1))) as pool: futures = [pool.submit(task, roots, workers) for task in tasks] for future in as_completed(futures): sessions.extend(future.result()) return _dedupe(sessions) def _dedupe(sessions: list[Session]) -> list[Session]: found: dict[tuple[str, str], Session] = {} for session in sessions: key = (session.platform, session.id) current = found.get(key) if current is None or _linkage_score(session) > _linkage_score(current): found[key] = session return list(found.values()) def _linkage_score(session: Session) -> int: return int(session.parent_id is not None) + int(session.agent is not None) -
sqlite_optional_scanners.py 7.5 KB
from __future__ import annotations import sqlite3 from collections.abc import Iterable from pathlib import Path from .jsonio import as_map, parse_json_text, text from .timeparse import file_time, unix_millis from .transcript import content_text, existing, flat_parallel, recent from .types import JsonMap, Session SqlValue = str | int | float | bytes | None SqlRow = tuple[SqlValue, ...] def scan_kilo_cli(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: paths = existing([Path.home() / ".local" / "share" / "kilo" / "kilo.db", *(root / "kilo.db" for root in extra_roots)]) return flat_parallel(recent(paths), workers, lambda path: _message_table_sessions(path, "kilo-cli", "message")) def scan_hermes(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: paths = existing([Path.home() / ".hermes" / "state.db", *(root / "state.db" for root in extra_roots)]) return flat_parallel(recent(paths), workers, lambda path: _message_table_sessions(path, "hermes", "messages")) def scan_goose(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: defaults = [ Path.home() / ".local" / "share" / "goose" / "sessions" / "sessions.db", Path.home() / "Library" / "Application Support" / "goose" / "sessions" / "sessions.db", ] paths = existing([*defaults, *(root / "sessions.db" for root in extra_roots)]) return flat_parallel(recent(paths), workers, lambda path: _message_table_sessions(path, "goose", "messages")) def scan_crush(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: paths = existing([Path.home() / ".local" / "share" / "crush" / "crush.db", *(root / "crush.db" for root in extra_roots)]) return flat_parallel(recent(paths), workers, _crush_sessions) def scan_zed(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: paths = existing([Path.home() / "Library" / "Application Support" / "Zed" / "threads" / "threads.db", *(root / "threads.db" for root in extra_roots)]) return flat_parallel(recent(paths), workers, _zed_sessions) def _message_table_sessions(path: Path, platform: str, table: str) -> list[Session]: try: with sqlite3.connect(path) as conn: rows = _fetch_all(conn, f"select session_id, role, data, created_at from {table} order by created_at") except sqlite3.Error: return [] return _group_message_rows(platform, path, rows) def _crush_sessions(path: Path) -> list[Session]: try: with sqlite3.connect(path) as conn: rows = _fetch_all(conn, "select session_id, role, parts, created_at from messages order by created_at") except sqlite3.Error: return [] return _group_message_rows("crush", path, rows) def _zed_sessions(path: Path) -> list[Session]: try: with sqlite3.connect(path) as conn: rows = _fetch_all(conn, "select id, data_type, data, updated_at from threads") except sqlite3.Error: return [] sessions: list[Session] = [] for row in rows: if _row_text(row, 1) != "json": continue data = _json_blob(_row_bytes(row, 2)) messages = _json_messages(data) first_user, last_user = _message_edges(messages) if not first_user: continue sid = _row_text(row, 0) or path.stem sessions.append(Session("zed", sid, str(path), None, _row_text(row, 3) or file_time(path), _row_text(row, 3) or file_time(path), "zed.dev", _zed_model(data), first_user, {"message_count": len(messages)}, last_user_message=last_user)) return sessions def _group_message_rows(platform: str, path: Path, rows: Iterable[SqlRow]) -> list[Session]: grouped: dict[str, list[JsonMap]] = {} stamps: dict[str, str] = {} for row in rows: sid = _row_text(row, 0) if sid is None: continue message = _message_json(row) if message is None: continue if sid not in grouped: grouped[sid] = [] grouped[sid].append(message) if sid not in stamps: stamps[sid] = _stamp(row, 3) or file_time(path) or "" sessions: list[Session] = [] for sid, messages in grouped.items(): first_user, last_user = _message_edges(messages) if not first_user: continue sessions.append(Session(platform, sid, str(path), None, stamps.get(sid) or file_time(path), file_time(path), _provider(messages), _model(messages), first_user, {"message_count": len(messages)}, last_user_message=last_user)) return sessions def _message_json(row: SqlRow) -> JsonMap | None: role = _row_text(row, 1) raw = _row_json(row, 2) if raw is None: return {"role": role or "", "content": _row_text(row, 2) or ""} if "role" not in raw: raw["role"] = role or text(raw.get("role")) or "" return raw def _message_edges(messages: list[JsonMap]) -> tuple[str, str]: first_user = last_user = "" for message in messages: if text(message.get("role")) != "user": continue prompt = _content(message) if prompt: first_user = first_user or prompt last_user = prompt return first_user, last_user def _content(message: JsonMap) -> str: for key in ("content", "parts", "text"): value = message.get(key) parsed = parse_json_text(value) if isinstance(value, str) else value result = content_text(parsed) or (value if isinstance(value, str) else "") if result: return result return "" def _json_messages(data: JsonMap | None) -> list[JsonMap]: if data is None: return [] for key in ("messages", "turns", "entries"): value = data.get(key) if isinstance(value, list): return [item for item in value if isinstance(item, dict)] return [] def _model(messages: list[JsonMap]) -> str | None: for message in messages: model = text(message.get("modelID")) or text(message.get("model_id")) or text(message.get("model")) if model: return model return None def _provider(messages: list[JsonMap]) -> str | None: for message in messages: provider = text(message.get("providerID")) or text(message.get("provider_id")) or text(message.get("provider")) if provider: return provider return None def _zed_model(data: JsonMap | None) -> str | None: model = as_map(data.get("model")) if data is not None else None return text(model.get("model")) if model is not None else None def _fetch_all(conn: sqlite3.Connection, sql: str) -> list[SqlRow]: rows: list[SqlRow] = conn.execute(sql).fetchall() return rows def _row_json(row: SqlRow, index: int) -> JsonMap | None: value = _row_text(row, index) return as_map(parse_json_text(value)) if value is not None else None def _json_blob(value: bytes | None) -> JsonMap | None: if value is None: return None try: return as_map(parse_json_text(value.decode())) except UnicodeDecodeError: return None def _row_text(row: SqlRow, index: int) -> str | None: value = row[index] if index < len(row) else None return text(value) if not isinstance(value, bytes) else None def _row_bytes(row: SqlRow, index: int) -> bytes | None: value = row[index] if index < len(row) else None return value if isinstance(value, bytes) else None def _stamp(row: SqlRow, index: int) -> str | None: value = row[index] if index < len(row) else None if isinstance(value, int): return unix_millis(value) if value > 10_000_000_000 else None return value if isinstance(value, str) else None -
sqlite_scanners.py 5.9 KB
from __future__ import annotations import sqlite3 from pathlib import Path from typing import TypeAlias from .jsonio import as_map, parse_json_text, text from .timeparse import file_time, unix_millis from .transcript import content_text, env_path, existing, flat_parallel, recent from .types import Json, JsonMap, Session SqlValue: TypeAlias = str | int | float | bytes | None SqlRow: TypeAlias = tuple[SqlValue, ...] def scan_kodu(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: appdata = env_path("APPDATA") roots = _roots( [ Path.home() / "Library" / "Application Support" / "Code" / "User" / "globalStorage" / "kodu-ai.claude-dev-experimental", Path.home() / ".config" / "Code" / "User" / "globalStorage" / "kodu-ai.claude-dev-experimental", *(path / "Code" / "User" / "globalStorage" / "kodu-ai.claude-dev-experimental" for path in (appdata,) if path is not None), ], extra_roots, ("kodu-ai.claude-dev-experimental",), ) paths = [root / "db" / "Azad.db" for root in roots if (root / "db" / "Azad.db").exists()] return flat_parallel(recent(paths), workers, _kodu_db) def scan_cursor_cli(extra_roots: tuple[Path, ...], workers: int) -> list[Session]: roots = _roots([Path.home() / ".cursor"], extra_roots, (".cursor",)) paths = [path for root in roots for path in (root / "chats").glob("*/*/store.db")] return flat_parallel(recent(paths), workers, _cursor_db) def _kodu_db(path: Path) -> list[Session]: try: with sqlite3.connect(path) as conn: tasks = _fetch_all( conn, "select id, name, dir_absolute_path, created_at, updated_at, tokens_in, tokens_out, cache_reads, cache_writes, cost from tasks" ) return [_kodu_session(path, conn, row) for row in tasks] except sqlite3.Error: return [] def _kodu_session(path: Path, conn: sqlite3.Connection, row: SqlRow) -> Session: sid = _row_text(row, 0) or "" messages = _fetch_all( conn, "select role, content, model_id, started_at, finished_at, tokens_in, tokens_out, cache_reads, cache_writes, cost from messages where task_id = ? order by started_at", (sid,), ) first_user = last_user = "" model = None for message in messages: model = model or _row_text(message, 2) if _row_text(message, 0) == "user": prompt = _content(_row_text(message, 1)) first_user = first_user or prompt last_user = prompt return Session( "kodu", sid, str(path), _row_text(row, 2), unix_millis(_row_int(row, 3)), unix_millis(_row_int(row, 4)) or file_time(path), None, model, first_user, _usage(row, (5, "input"), (6, "output"), (7, "cacheRead"), (8, "cacheWrite"), (9, "cost_total")), last_user_message=last_user, ) def _cursor_db(path: Path) -> list[Session]: try: with sqlite3.connect(path) as conn: rows = _fetch_all(conn, "select data from blobs") except sqlite3.Error: return [] first_user = last_user = "" for row in rows: data = _json_blob(_row_bytes(row, 0)) if data is None or data.get("role") != "user": continue prompt = _cursor_prompt(_content(data.get("content"))) if prompt: first_user = first_user or prompt last_user = prompt if not first_user: return [] return [ Session( "cursor-cli", path.parent.name, str(path), None, file_time(path), file_time(path), "cursor", None, first_user, {"blob_count": len(rows)}, last_user_message=last_user, ) ] def _content(value: Json | None) -> str: parsed = parse_json_text(value) if isinstance(value, str) else value return content_text(parsed) or (value if isinstance(value, str) else "") def _json_blob(value: bytes | None) -> JsonMap | None: if value is None: return None try: decoded = value.decode() except UnicodeDecodeError: return None return as_map(parse_json_text(decoded)) def _cursor_prompt(value: str) -> str: start = value.find("<user_query>") end = value.find("</user_query>") if start >= 0 and end > start: return value[start + len("<user_query>") : end].strip() if value.lstrip().startswith("<user_info>"): return "" return value.strip() def _usage(row: SqlRow, *fields: tuple[int, str]) -> JsonMap: result: JsonMap = {} for index, key in fields: value = _row_number(row, index) if value is not None: result[key] = value return result def _fetch_all(conn: sqlite3.Connection, sql: str, params: tuple[str, ...] = ()) -> list[SqlRow]: rows: list[SqlRow] = conn.execute(sql, params).fetchall() return rows def _row_text(row: SqlRow, index: int) -> str | None: value = row[index] if index < len(row) else None return text(value) if not isinstance(value, bytes) else None def _row_bytes(row: SqlRow, index: int) -> bytes | None: value = row[index] if index < len(row) else None return value if isinstance(value, bytes) else None def _row_number(row: SqlRow, index: int) -> int | float | None: value = row[index] if index < len(row) else None return value if isinstance(value, int | float) else None def _row_int(row: SqlRow, index: int) -> int | None: value = row[index] if index < len(row) else None return value if isinstance(value, int) else None def _roots(defaults: list[Path], extra_roots: tuple[Path, ...], children: tuple[str, ...]) -> list[Path]: candidates = [*defaults] for root in extra_roots: candidates.append(root) candidates.extend(root / child for child in children) return existing(candidates) -
timeparse.py 1.9 KB
from __future__ import annotations from datetime import datetime, timedelta, timezone from pathlib import Path def date_bound(value: str | None, end: bool = False) -> datetime | None: if value is None: return None text = value.strip().lower() now = datetime.now(timezone.utc) if text == "today": base = now.replace(hour=0, minute=0, second=0, microsecond=0) elif text == "yesterday": base = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) elif text.endswith("d") and text[:-1].isdigit(): base = (now - timedelta(days=int(text[:-1]))).replace(hour=0, minute=0, second=0, microsecond=0) else: parts = [int(part) for part in text.split("-")] base = datetime(parts[0], parts[1] if len(parts) > 1 else 1, parts[2] if len(parts) > 2 else 1, tzinfo=timezone.utc) if not end: return base if text in {"today", "yesterday"} or text.endswith("d") or len(text.split("-")) == 3: return base + timedelta(days=1) if len(text.split("-")) == 2: return datetime(base.year + (base.month // 12), (base.month % 12) + 1, 1, tzinfo=timezone.utc) return datetime(base.year + 1, 1, 1, tzinfo=timezone.utc) def parse_stamp(value: str | None) -> datetime | None: if value is None: return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) except ValueError: return None def unix_seconds(value: int | float | None) -> str | None: return datetime.fromtimestamp(value, timezone.utc).isoformat() if value is not None else None def unix_millis(value: int | None) -> str | None: return datetime.fromtimestamp(value / 1000, timezone.utc).isoformat() if value is not None else None def file_time(path: Path) -> str | None: try: return datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat() except OSError: return None -
transcript.py 5.5 KB
from __future__ import annotations import os from concurrent.futures import ThreadPoolExecutor, as_completed from heapq import nlargest from pathlib import Path from typing import Callable from .jsonio import as_map, iter_jsonl, parse_json_text, text from .timeparse import file_time from .types import Json, JsonMap, Session MAX_PLATFORM_FILES = 2000 def jsonl_parallel(paths: list[Path], workers: int, platform: str, fallback_id: Callable[[Path], str]) -> list[Session]: if not paths: return [] sessions: list[Session] = [] with ThreadPoolExecutor(max_workers=min(workers, max(len(paths), 1))) as pool: futures = [pool.submit(jsonl_session, platform, path, fallback_id(path)) for path in paths] for future in as_completed(futures): sessions.append(future.result()) return sessions def flat_parallel(paths: list[Path], workers: int, read: Callable[[Path], list[Session]]) -> list[Session]: if not paths: return [] sessions: list[Session] = [] with ThreadPoolExecutor(max_workers=min(workers, max(len(paths), 1))) as pool: futures = [pool.submit(read, path) for path in paths] for future in as_completed(futures): sessions.extend(future.result()) return sessions def jsonl_session(platform: str, path: Path, fallback_id: str) -> Session: sid = fallback_id cwd = provider = model = first_user = parent = agent = None last_user = "" created = updated = None usage: JsonMap = {} for data in iter_jsonl(path): event_type = data.get("type") session_line_id = text(data.get("id")) if event_type == "session" else None sid = text(data.get("sessionId")) or session_line_id or sid cwd = cwd or text(data.get("cwd")) created = created or text(data.get("timestamp")) updated = text(data.get("timestamp")) or updated provider = provider or text(data.get("provider")) model = model or text(data.get("modelId")) or text(data.get("model")) payload = as_map(data.get("payload")) if event_type == "session_meta" and payload is not None: sid = text(payload.get("id")) or sid cwd = cwd or text(payload.get("cwd")) provider = provider or text(payload.get("model_provider")) source_parent, source_agent = spawn_info(payload.get("source")) parent = parent or source_parent agent = agent or source_agent or nick_role(text(payload.get("agent_nickname")), text(payload.get("agent_role"))) message = as_map(data.get("message")) or payload or {} provider = provider or text(message.get("provider")) model = model or text(message.get("model")) prompt = user_text(data, message) if prompt: first_user = first_user or prompt last_user = prompt merge_usage(usage, as_map(message.get("usage")) or as_map(data.get("usage"))) return Session(platform, sid, str(path), cwd, created or file_time(path), updated or created or file_time(path), provider, model, first_user or "", usage, parent, agent, last_user) def spawn_info(source: Json | None) -> tuple[str | None, str | None]: data = as_map(parse_json_text(source) if isinstance(source, str) else source) if data is None: return None, None subagent = data.get("subagent") if isinstance(subagent, str): return None, subagent subagent_map = as_map(subagent) spawn = as_map(subagent_map.get("thread_spawn")) if subagent_map is not None else None if spawn is None: return None, None return text(spawn.get("parent_thread_id")), nick_role(text(spawn.get("agent_nickname")), text(spawn.get("agent_role"))) def nick_role(nickname: str | None, role: str | None) -> str | None: if nickname and role: return f"{nickname} ({role})" return nickname or role def user_text(data: JsonMap, message: JsonMap) -> str: if data.get("type") == "user": value = content_text(data.get("content")) if value: return value if message.get("role") == "user": return content_text(message.get("content")) return "" def content_text(value: Json | None) -> str: if isinstance(value, str): return value if isinstance(value, list): parts = [text(item.get("text")) or text(item.get("content")) or "" for item in value if isinstance(item, dict)] return "\n".join(part for part in parts if part) return "" def merge_usage(target: JsonMap, value: JsonMap | None) -> None: if value is None: return for key in ("totalTokens", "total_tokens", "input", "output", "cacheRead", "cacheWrite"): if key in value: target[key] = value[key] cost = as_map(value.get("cost")) if cost is not None and "total" in cost: target["cost_total"] = cost["total"] def existing(paths: list[Path]) -> list[Path]: seen: set[Path] = set() result: list[Path] = [] for path in paths: if str(path) and path.exists() and path not in seen: seen.add(path) result.append(path) return result def recent(paths: list[Path]) -> list[Path]: return nlargest(MAX_PLATFORM_FILES, paths, key=modified_time) def modified_time(path: Path) -> float: try: return path.stat().st_mtime except OSError: return 0.0 def stem_id(path: Path, marker: str) -> str: return path.stem.split(marker)[-1].split("_")[-1] def env_path(name: str) -> Path | None: value = os.environ.get(name) return Path(value).expanduser() if value else None -
types.py 1.5 KB
from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import TypeAlias Json: TypeAlias = str | int | float | bool | None | list["Json"] | dict[str, "Json"] JsonMap: TypeAlias = dict[str, Json] @dataclass(frozen=True, slots=True) class Options: platforms: frozenset[str] roots: tuple[Path, ...] queries: tuple[str, ...] date_from: str | None date_to: str | None cwd: str | None model: str | None limit: int workers: int include_subagents: bool @dataclass(frozen=True, slots=True) class Session: platform: str id: str path: str cwd: str | None created_at: str | None updated_at: str | None provider: str | None model: str | None first_user_message: str usage: JsonMap parent_id: str | None = None agent: str | None = None last_user_message: str = "" def to_json(self) -> JsonMap: return { "platform": self.platform, "id": self.id, "path": self.path, "cwd": self.cwd, "created_at": self.created_at, "updated_at": self.updated_at, "provider": self.provider, "model": self.model, "first_user_message": self.first_user_message[:300], "last_user_message": (self.last_user_message or self.first_user_message)[:300], "usage": self.usage, "parent_id": self.parent_id, "agent": self.agent, } -
__init__.py 35 B
from __future__ import annotations
-
-
tests
-
test_agent_sessions.py 14.4 KB
# /// script # requires-python = ">=3.11" # dependencies = ["pytest"] # /// # --- How to run --- # uv run --with pytest pytest scripts/tests/test_agent_sessions.py -v # pyright: reportImplicitRelativeImport=false, reportMissingImports=false from __future__ import annotations import json import os import sqlite3 import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from agent_sessions import cli, scanners from agent_sessions.opencode import scan_opencode from agent_sessions.scanners import scan_claude, scan_codex from agent_sessions.transcript import MAX_PLATFORM_FILES, recent from agent_sessions.types import Json, JsonMap, Session def _map(value: Json) -> JsonMap: assert isinstance(value, dict) return value def _rows(payload: JsonMap, key: str) -> list[JsonMap]: value = payload[key] assert isinstance(value, list) return [_map(item) for item in value] def _claude_line(session_id: str, content: str, agent_id: str | None = None) -> str: data: JsonMap = { "sessionId": session_id, "type": "user", "timestamp": "2026-06-10T06:56:20.048Z", "cwd": "/tmp/work", "message": {"role": "user", "content": content}, } if agent_id is not None: data["agentId"] = agent_id data["isSidechain"] = True return json.dumps(data) def _session(platform: str, sid: str, parent_id: str | None = None, agent: str | None = None, path: str = "/tmp/x.x") -> Session: return Session(platform, sid, path, "/tmp/work", "2026-06-10T00:00:00+00:00", None, None, None, "hello world", {}, parent_id, agent) @pytest.fixture def claude_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: project = tmp_path / ".claude" / "projects" / "-tmp-work" sub = project / "main-sid" / "subagents" wf = sub / "workflows" / "wf_1" wf.mkdir(parents=True) (project / "main-sid.jsonl").write_text(_claude_line("main-sid", "build the feature") + "\n") (sub / "agent-abc.jsonl").write_text(_claude_line("main-sid", "audit repos", "abc") + "\n") (sub / "agent-abc.meta.json").write_text(json.dumps({"agentType": "general-purpose", "description": "Security sweep", "toolUseId": "toolu_1"})) (wf / "agent-def.jsonl").write_text(_claude_line("main-sid", "verify finding", "def") + "\n") (wf / "agent-def.meta.json").write_text(json.dumps({"agentType": "Explore", "description": "Verify finding"})) (wf / "journal.jsonl").write_text(json.dumps({"type": "journal", "sessionId": "main-sid"}) + "\n") monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("APPDATA", "") return tmp_path def test_claude_main_session_keeps_its_own_transcript_path(claude_home: Path) -> None: sessions = scan_claude((), 4) mains = [item for item in sessions if item.id == "main-sid"] assert len(mains) == 1, f"expected exactly one main-sid session, got {mains}" assert mains[0].path.endswith("main-sid.jsonl"), f"main session path hijacked: {mains[0].path}" assert mains[0].parent_id is None def test_claude_subagent_transcripts_become_child_sessions(claude_home: Path) -> None: sessions = scan_claude((), 4) by_id = {item.id: item for item in sessions} assert "abc" in by_id, f"task subagent missing from {sorted(by_id)}" assert "def" in by_id, f"workflow subagent missing from {sorted(by_id)}" assert by_id["abc"].parent_id == "main-sid" assert by_id["def"].parent_id == "main-sid" assert by_id["abc"].agent == "general-purpose" assert "audit repos" in by_id["abc"].first_user_message assert "Security sweep" in by_id["abc"].first_user_message, "meta description must be searchable" def test_claude_workflow_journal_is_not_a_session(claude_home: Path) -> None: sessions = scan_claude((), 4) assert [item for item in sessions if item.path.endswith("journal.jsonl")] == [], "journal.jsonl must not be scanned as a session" CODEX_NEW_SCHEMA = ( "CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, created_at INTEGER NOT NULL, " "updated_at INTEGER NOT NULL, source TEXT NOT NULL, model_provider TEXT NOT NULL, cwd TEXT NOT NULL, " "model TEXT, first_user_message TEXT NOT NULL DEFAULT '', tokens_used INTEGER NOT NULL DEFAULT 0, " "agent_nickname TEXT, agent_role TEXT)" ) CODEX_OLD_SCHEMA = ( "CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, created_at INTEGER NOT NULL, " "updated_at INTEGER NOT NULL, model_provider TEXT NOT NULL, cwd TEXT NOT NULL, " "model TEXT, first_user_message TEXT NOT NULL DEFAULT '', tokens_used INTEGER NOT NULL DEFAULT 0)" ) SPAWN_SOURCE = json.dumps({"subagent": {"thread_spawn": {"parent_thread_id": "parent-1", "depth": 1, "agent_nickname": "Mencius", "agent_role": "worker"}}}) @pytest.fixture def codex_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: codex = tmp_path / ".codex" codex.mkdir(parents=True) monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("CODEX_HOME", str(codex)) return codex def test_codex_spawn_edges_link_child_to_parent(codex_home: Path) -> None: with sqlite3.connect(codex_home / "state_9.sqlite") as conn: conn.execute(CODEX_NEW_SCHEMA) conn.execute("CREATE TABLE thread_spawn_edges (parent_thread_id TEXT NOT NULL, child_thread_id TEXT NOT NULL PRIMARY KEY, status TEXT NOT NULL)") conn.execute("INSERT INTO threads VALUES ('parent-1', '/tmp/p.jsonl', 100, 200, 'cli', 'openai', '/tmp/work', 'gpt-5', 'do it', 9, NULL, NULL)") conn.execute( "INSERT INTO threads VALUES ('child-1', '/tmp/c.jsonl', 150, 210, ?, 'openai', '/tmp/work', 'gpt-5', 'sub task', 3, 'Mencius', 'worker')", (SPAWN_SOURCE,), ) conn.execute("INSERT INTO thread_spawn_edges VALUES ('parent-1', 'child-1', 'closed')") sessions = {item.id: item for item in scan_codex((), 4)} assert sessions["parent-1"].parent_id is None assert sessions["child-1"].parent_id == "parent-1" assert sessions["child-1"].agent == "Mencius (worker)" def test_codex_old_schema_still_lists_threads(codex_home: Path) -> None: with sqlite3.connect(codex_home / "state_5.sqlite") as conn: conn.execute(CODEX_OLD_SCHEMA) conn.execute("INSERT INTO threads VALUES ('old-1', '/tmp/o.jsonl', 100, 200, 'openai', '/tmp/work', 'gpt-5', 'legacy', 1)") sessions = {item.id: item for item in scan_codex((), 4)} assert "old-1" in sessions, "old-schema codex db must still be scanned" assert sessions["old-1"].parent_id is None def test_codex_rollout_session_meta_recovers_id_and_parent(codex_home: Path) -> None: day = codex_home / "sessions" / "2026" / "06" / "01" day.mkdir(parents=True) meta: JsonMap = { "timestamp": "2026-06-01T00:00:00.000Z", "type": "session_meta", "payload": { "id": "child-9", "timestamp": "2026-06-01T00:00:00.000Z", "cwd": "/tmp/work", "model_provider": "openai", "source": {"subagent": {"thread_spawn": {"parent_thread_id": "parent-9", "depth": 1, "agent_nickname": "Tesla", "agent_role": "explorer"}}}, }, } user: JsonMap = { "timestamp": "2026-06-01T00:00:01.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "spawned task prompt"}]}, } (day / "rollout-2026-06-01T00-00-00-child-9.jsonl").write_text(json.dumps(meta) + "\n" + json.dumps(user) + "\n") sessions = {item.id: item for item in scan_codex((), 4)} assert "child-9" in sessions, f"payload id not recovered: {sorted(sessions)}" assert sessions["child-9"].parent_id == "parent-9" assert sessions["child-9"].agent == "Tesla (explorer)" assert sessions["child-9"].cwd == "/tmp/work" assert "spawned task prompt" in sessions["child-9"].first_user_message OPENCODE_SCHEMA = ( "CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, directory TEXT NOT NULL, title TEXT NOT NULL, " "agent TEXT, model TEXT, cost REAL DEFAULT 0, tokens_input INTEGER DEFAULT 0, tokens_output INTEGER DEFAULT 0, " "tokens_reasoning INTEGER DEFAULT 0, tokens_cache_read INTEGER DEFAULT 0, tokens_cache_write INTEGER DEFAULT 0, " "time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER)" ) def test_opencode_db_children_carry_parent_and_agent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: db = tmp_path / "opencode.db" with sqlite3.connect(db) as conn: conn.execute(OPENCODE_SCHEMA) conn.execute( "INSERT INTO session (id, parent_id, directory, title, agent, model, time_created, time_updated) " "VALUES ('ses_main', NULL, '/tmp/work', 'main work', NULL, '{\"providerID\":\"anthropic\",\"id\":\"claude\"}', 1000, 2000)" ) conn.execute( "INSERT INTO session (id, parent_id, directory, title, agent, model, time_created, time_updated) " "VALUES ('ses_child', 'ses_main', '/tmp/work', 'explore docs (@explore subagent)', 'explore', NULL, 1100, 1900)" ) monkeypatch.setattr("agent_sessions.opencode._db_path", lambda: db) sessions = {item.id: item for item in scan_opencode((), 4)} assert "ses_child" in sessions, f"child sessions missing from db scan: {sorted(sessions)}" assert sessions["ses_child"].parent_id == "ses_main" assert sessions["ses_child"].agent == "explore" assert sessions["ses_main"].parent_id is None def test_opencode_storage_fallback_reads_parent_id(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = tmp_path / "opencode" store = root / "storage" / "session" / "hash1" store.mkdir(parents=True) (store / "ses_main.json").write_text(json.dumps({"id": "ses_main", "title": "main", "directory": "/tmp/work", "time": {"created": 1000, "updated": 2000}})) (store / "ses_child.json").write_text(json.dumps({"id": "ses_child", "parentID": "ses_main", "title": "child task", "directory": "/tmp/work", "time": {"created": 1100, "updated": 1900}})) monkeypatch.setattr(Path, "home", lambda: tmp_path / "nohome") sessions = {item.id: item for item in scan_opencode((root,), 4)} assert "ses_child" in sessions, f"storage fallback missing children: {sorted(sessions)}" assert sessions["ses_child"].parent_id == "ses_main" assert sessions["ses_main"].parent_id is None def test_empty_env_roots_do_not_scan_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") monkeypatch.setenv("APPDATA", "") monkeypatch.setenv("CODEX_HOME", "") monkeypatch.setenv("OPENCODE_HOME", "") monkeypatch.setattr("agent_sessions.opencode._db_path", lambda: None) monkeypatch.setattr("agent_sessions.opencode._opencode_json", lambda _args: []) with sqlite3.connect(tmp_path / "state_leak.sqlite") as conn: conn.execute(CODEX_OLD_SCHEMA) conn.execute("INSERT INTO threads VALUES ('cwd-codex', '/tmp/o.jsonl', 100, 200, 'openai', '/tmp/work', 'gpt-5', 'cwd leak', 1)") (tmp_path / "Claude" / "transcripts").mkdir(parents=True) (tmp_path / "Claude" / "transcripts" / "cwd-claude.jsonl").write_text(_claude_line("cwd-claude", "cwd leak") + "\n") store = tmp_path / "storage" / "session" / "hash1" store.mkdir(parents=True) (store / "ses_cwd.json").write_text(json.dumps({"id": "ses_cwd", "title": "cwd leak", "directory": "/tmp/work", "time": {"created": 1000, "updated": 2000}})) assert scan_codex((), 4) == [] assert scan_claude((), 4) == [] assert scan_opencode((), 4) == [] def test_recent_keeps_newest_platform_file_cap(tmp_path: Path) -> None: paths: list[Path] = [] for index in range(MAX_PLATFORM_FILES + 3): path = tmp_path / f"session-{index}.jsonl" path.write_text("{}\n") os.utime(path, (index, index)) paths.append(path) missing = tmp_path / "missing.jsonl" selected = recent([missing, *paths]) assert len(selected) == MAX_PLATFORM_FILES assert [path.name for path in selected[:3]] == ["session-2002.jsonl", "session-2001.jsonl", "session-2000.jsonl"] assert "session-0.jsonl" not in {path.name for path in selected} assert missing not in selected @pytest.fixture def family() -> list[Session]: return [ _session("opencode", "ses_main", path="/tmp/main.jsonl"), _session("opencode", "ses_child1", parent_id="ses_main", agent="explore"), _session("opencode", "ses_child2", parent_id="ses_main", agent="plan"), ] def test_cli_list_hides_children_and_counts_them(family: list[Session]) -> None: payload = cli._list_payload(family, family, 10, include_subagents=False) results = _rows(payload, "results") ids = [item["id"] for item in results] assert ids == ["ses_main"], f"children leaked into default list: {ids}" assert results[0]["subagent_count"] == 2 def test_cli_list_include_subagents_keeps_children(family: list[Session]) -> None: payload = cli._list_payload(family, family, 10, include_subagents=True) assert {item["id"] for item in _rows(payload, "results") if isinstance(item["id"], str)} == {"ses_main", "ses_child1", "ses_child2"} def test_cli_get_main_includes_children(family: list[Session]) -> None: payload = cli._get_payload(family, ["ses_main"]) assert payload["count"] == 1 result = _rows(payload, "results")[0] assert sorted(item["id"] for item in _rows(result, "subagents") if isinstance(item["id"], str)) == ["ses_child1", "ses_child2"] assert _map(result["session"])["subagent_count"] == 2 def test_cli_get_child_by_id_still_works(family: list[Session]) -> None: payload = cli._get_payload(family, ["ses_child1"]) assert payload["count"] == 1 assert _map(_rows(payload, "results")[0]["session"])["parent_id"] == "ses_main" def test_dedupe_prefers_linked_session_regardless_of_order() -> None: bare = _session("codex", "t1") linked = _session("codex", "t1", parent_id="t0", agent="Tesla (explorer)") for ordering in ([bare, linked], [linked, bare]): result = scanners._dedupe(ordering) assert len(result) == 1 assert result[0].parent_id == "t0", f"dedupe dropped spawn linkage for ordering starting with {ordering[0]}" def test_cli_search_matches_agent_name(family: list[Session]) -> None: payload = cli._search_payload(family, family, ("explore",), 10, 2, include_subagents=True) assert "ses_child1" in {item["id"] for item in _rows(payload, "results") if isinstance(item["id"], str)}, "agent label must be searchable" -
test_aside_scanner.py 5.4 KB
# /// script # requires-python = ">=3.11" # dependencies = ["pytest"] # /// # --- How to run --- # uv run --with pytest pytest scripts/tests/test_aside_scanner.py -v # pyright: reportImplicitRelativeImport=false, reportMissingImports=false from __future__ import annotations import json import sqlite3 import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from agent_sessions.aside_scanner import scan_aside from agent_sessions.types import JsonMap, Session def _write_jsonl(path: Path, rows: list[JsonMap]) -> None: path.parent.mkdir(parents=True, exist_ok=True) _ = path.write_text("\n".join(json.dumps(row) for row in rows) + "\n") def _write_state_db(path: Path, rows: list[tuple[str, str | None, str, str, str, int, int]]) -> None: path.parent.mkdir(parents=True, exist_ok=True) with sqlite3.connect(path) as conn: _ = conn.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY, parent_id TEXT, title TEXT NOT NULL, cwd TEXT NOT NULL, model TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)") _ = conn.executemany("INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?)", rows) def _by_id(sessions: list[Session]) -> dict[str, Session]: return {item.id: item for item in sessions} def test_scan_aside_reads_messages_and_state_db_index(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # given: a main session, a state.db-linked child, and a hardlink-mirror agents dir monkeypatch.setattr(Path, "home", lambda: tmp_path) user = tmp_path / ".aside" / "u" / "0" model = json.dumps({"provider": "quotio", "modelId": "gpt-5.6-sol", "thinkingLevel": "medium"}) _write_state_db( user / "state.db", [ ("main1", None, "Ambassador research", "/tmp/aside-main", model, 1785295598, 1785377046), ("child1", "main1", "Check settings context", "/tmp/aside-main", model, 1785295600, 1785295700), ], ) _write_jsonl( user / "sessions" / "2026-07-29_main1" / "messages.jsonl", [ {"role": "system-message", "content": "skill docs", "timestamp": 1785295598327}, {"role": "user", "content": "find the ambassador benefits", "timestamp": 1785295598204}, {"role": "assistant", "content": [{"type": "text", "text": "on it"}], "provider": "apitopia", "model": "kimi-k3", "usage": {"input": 28019, "output": 406, "totalTokens": 28425, "cost": {"total": 0.090147}}, "timestamp": 1785295598449}, {"role": "user", "content": "now summarize it", "timestamp": 1785295599000}, ], ) _write_jsonl( user / "sessions" / "2026-07-29_child1" / "messages.jsonl", [{"role": "user", "content": "verify the settings page", "timestamp": 1785295600000}], ) _write_jsonl( user / "agents" / "main" / "sessions" / "2026-07-29_main1" / "messages.jsonl", [{"role": "user", "content": "mirror copy that must not be scanned", "timestamp": 1785295601000}], ) # when sessions = _by_id(scan_aside((), 4)) # then: state.db metadata wins, transcript supplies prompts and usage assert len(sessions) == 2 main = sessions["main1"] assert main.platform == "aside" assert main.agent is None assert "/agents/" not in main.path assert main.first_user_message == "find the ambassador benefits" assert main.last_user_message == "now summarize it" assert main.cwd == "/tmp/aside-main" assert main.provider == "quotio" assert main.model == "gpt-5.6-sol" assert main.parent_id is None assert main.usage["totalTokens"] == 28425 assert main.usage["cost_total"] == 0.090147 assert (main.created_at or "").startswith("2026-07-29") child = sessions["child1"] assert child.parent_id == "main1" assert child.agent == "Check settings context" assert child.first_user_message == "verify the settings page" def test_scan_aside_without_state_db_falls_back_to_transcript(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # given: only messages.jsonl, no state.db monkeypatch.setattr(Path, "home", lambda: tmp_path) user = tmp_path / ".aside" / "u" / "1" _write_jsonl( user / "sessions" / "2026-07-30_solo1" / "messages.jsonl", [ {"role": "user", "content": "bare prompt", "timestamp": 1785295598204}, {"role": "assistant", "content": [{"type": "text", "text": "ok"}], "provider": "apitopia", "model": "kimi-k3", "timestamp": 1785295598449}, ], ) # when sessions = _by_id(scan_aside((), 4)) # then solo = sessions["solo1"] assert solo.first_user_message == "bare prompt" assert solo.provider == "apitopia" assert solo.model == "kimi-k3" assert solo.cwd is None assert (solo.created_at or "").startswith("2026-07-29") def test_scan_aside_accepts_root_pointing_at_user_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # given: an extra root that is itself a user dir (sessions/ directly inside) monkeypatch.setattr(Path, "home", lambda: tmp_path / "nowhere") user = tmp_path / "exported-aside-user" _write_jsonl( user / "sessions" / "2026-07-30_rooted1" / "messages.jsonl", [{"role": "user", "content": "rooted prompt", "timestamp": 1785295598204}], ) # when sessions = _by_id(scan_aside((user,), 4)) # then assert sessions["rooted1"].first_user_message == "rooted prompt" -
test_cli_contract.py 4.6 KB
# /// script # requires-python = ">=3.11" # dependencies = ["pytest"] # /// # --- How to run --- # uv run --with pytest pytest scripts/tests/test_cli_contract.py -v # pyright: reportImplicitRelativeImport=false, reportMissingImports=false from __future__ import annotations import json import os import sqlite3 import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from agent_sessions.jsonio import as_map, parse_json_text from agent_sessions.types import Json, JsonMap SKILL_ROOT = Path(__file__).resolve().parents[2] def _map(value: Json) -> JsonMap: assert isinstance(value, dict) return value def _rows(payload: JsonMap, key: str) -> list[JsonMap]: value = payload[key] assert isinstance(value, list) return [_map(item) for item in value] def _payload(text_value: str) -> JsonMap: value = as_map(parse_json_text(text_value)) assert value is not None return value def _run(root: Path, *args: str) -> JsonMap: env = os.environ.copy() env["APPDATA"] = str(root / "appdata") env["CODEX_HOME"] = str(root) env["HOME"] = str(root / "home") env["OPENCODE_HOME"] = str(root / "opencode-home") proc = subprocess.run( [sys.executable, "scripts/find-agent-sessions.py", *args, "--root", str(root)], cwd=SKILL_ROOT, env=env, check=True, capture_output=True, text=True, ) return _payload(proc.stdout) def _write_jsonl(path: Path, rows: list[JsonMap]) -> None: path.parent.mkdir(parents=True, exist_ok=True) _ = path.write_text("\n".join(json.dumps(row) for row in rows) + "\n") def _fixture_root(tmp_path: Path) -> Path: root = tmp_path / "agents" root.mkdir(parents=True) with sqlite3.connect(root / "state_test.sqlite") as conn: schema = ( "CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT NOT NULL, created_at INTEGER NOT NULL, " + "updated_at INTEGER NOT NULL, source TEXT NOT NULL, model_provider TEXT NOT NULL, cwd TEXT NOT NULL, " + "model TEXT, first_user_message TEXT NOT NULL DEFAULT '', tokens_used INTEGER NOT NULL DEFAULT 0, " + "agent_nickname TEXT, agent_role TEXT)" ) row = ( "INSERT INTO threads VALUES ('codex-alpha', '/tmp/codex-alpha.jsonl', 100, 200, 'cli', 'openai', " + "'/tmp/work', 'gpt-5', 'alpha rollout fix', 9, NULL, NULL)" ) _ = conn.execute(schema) _ = conn.execute(row) _write_jsonl( root / "transcripts" / "claude-beta.jsonl", [ {"sessionId": "claude-beta", "type": "user", "timestamp": "2026-06-10T00:00:00Z", "cwd": "/tmp/work", "content": "unrelated"}, {"sessionId": "claude-beta", "type": "user", "timestamp": "2026-06-10T00:00:03Z", "cwd": "/tmp/work", "content": "alpha review notes"}, ], ) return root def test_find_searches_all_platforms_and_explains_matches(tmp_path: Path) -> None: payload = _run(_fixture_root(tmp_path), "find", "alpha", "--limit", "10") results = _rows(payload, "results") platforms: set[str] = set() for item in results: platform = item["platform"] assert isinstance(platform, str) platforms.add(platform) assert platforms == {"codex", "claude"} for item in results: reasons = _rows(item, "match_reasons") assert reasons, f"missing match reasons for {item}" assert reasons[0]["query"] == "alpha" assert reasons[0]["platform"] == item["platform"] assert isinstance(reasons[0]["snippet"], str) and "alpha" in reasons[0]["snippet"].lower() assert item["detail_hint"] == f"python3 scripts/find-agent-sessions.py read {item['id']} --platform {item['platform']}" def test_platform_filter_narrows_find_results(tmp_path: Path) -> None: payload = _run(_fixture_root(tmp_path), "find", "alpha", "--platform", "codex") results = _rows(payload, "results") assert len(results) == 1 assert results[0]["platform"] == "codex" def test_read_summarizes_first_and_last_user_prompts(tmp_path: Path) -> None: payload = _run(_fixture_root(tmp_path), "read", "claude-beta", "--platform", "claude") result = _rows(payload, "results")[0] prompts = _map(result["prompts"]) session = _map(result["session"]) assert prompts["first_user_message"] == "unrelated" assert prompts["last_user_message"] == "alpha review notes" assert session["last_user_message"] == "alpha review notes" assert result["detail_hint"] == "python3 scripts/find-agent-sessions.py read claude-beta --platform claude" -
test_extended_scanners.py 11.3 KB
# /// script # requires-python = ">=3.11" # dependencies = ["pytest"] # /// # --- How to run --- # uv run --with pytest pytest scripts/tests/test_extended_scanners.py -v # pyright: reportImplicitRelativeImport=false, reportMissingImports=false from __future__ import annotations import json import sqlite3 import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from agent_sessions import scanners from agent_sessions.cli import _get_payload from agent_sessions.file_scanners import _file_uri_path from agent_sessions.sqlite_scanners import scan_kodu from agent_sessions.types import JsonMap, Session def _write_jsonl(path: Path, rows: list[JsonMap]) -> None: path.parent.mkdir(parents=True, exist_ok=True) _ = path.write_text("\n".join(json.dumps(row) for row in rows) + "\n") def _sessions_by_platform(items: list[Session]) -> dict[str, Session]: return {item.platform: item for item in items} def test_default_platforms_are_canonical_transcript_sources() -> None: required = { "codex", "claude", "senpi", "oh-my-pi", "gajae-code", "opencode", "openclaw", "droid", "amp", "gemini", "kimi", "qwen", "codebuff", "roo-code", "kilo-code", "cline", "kodu", "cursor-cli", "aider", "kilo-cli", "hermes", "goose", "crush", "zed", "kiro", "aside", } forbidden = {"copilot", "mux", "antigravity", "synthetic", "cursor"} assert scanners.DEFAULT_PLATFORMS == required assert not forbidden & scanners.DEFAULT_PLATFORMS assert scanners.PLATFORM_ALIASES["roocode"] == "roo-code" assert scanners.PLATFORM_ALIASES["kilocode"] == "kilo-code" assert scanners.PLATFORM_ALIASES["kilo"] == "kilo-cli" def test_extended_default_scanners_find_transcript_rich_stores(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("APPDATA", str(tmp_path / "appdata")) _write_jsonl( tmp_path / ".openclaw" / "agents" / "main" / "sessions" / "openclaw-1.jsonl", [ {"type": "session", "id": "openclaw-1", "timestamp": "2026-06-10T00:00:00Z", "cwd": "/tmp/openclaw"}, {"type": "message", "timestamp": "2026-06-10T00:00:01Z", "message": {"role": "user", "content": [{"type": "text", "text": "openclaw build"}]}}, ], ) _write_jsonl( tmp_path / ".factory" / "sessions" / "-tmp-droid" / "droid-1.jsonl", [ {"type": "session_start", "id": "droid-1", "timestamp": "2026-06-10T00:00:00Z", "cwd": "/tmp/droid"}, {"type": "message", "timestamp": "2026-06-10T00:00:01Z", "message": {"role": "user", "content": [{"type": "text", "text": "<system-reminder>noise</system-reminder>"}, {"type": "text", "text": "droid real prompt"}]}}, ], ) amp = tmp_path / ".local" / "share" / "amp" / "threads" amp.mkdir(parents=True) (amp / "T-amp-1.json").write_text( json.dumps( { "id": "T-amp-1", "created": 1771748797025, "env": {"initial": {"trees": [{"uri": "file:///tmp/amp"}], "tags": ["model:claude-opus-4-6"]}}, "messages": [{"role": "user", "content": [{"type": "text", "text": "amp prompt"}], "meta": {"sentAt": 1771748810692}}], } ) ) sessions = _sessions_by_platform(scanners.scan(scanners.DEFAULT_PLATFORMS, (), 4)) assert sessions["openclaw"].first_user_message == "openclaw build" assert sessions["droid"].first_user_message == "droid real prompt" assert sessions["amp"].first_user_message == "amp prompt" def test_sqlite_and_repo_scanners_use_bounded_default_roots(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("APPDATA", str(tmp_path / "appdata")) kodu_dir = tmp_path / "Library" / "Application Support" / "Code" / "User" / "globalStorage" / "kodu-ai.claude-dev-experimental" / "db" kodu_dir.mkdir(parents=True) with sqlite3.connect(kodu_dir / "Azad.db") as conn: conn.execute("CREATE TABLE tasks (id TEXT PRIMARY KEY, created_at INTEGER, updated_at INTEGER, name TEXT, dir_absolute_path TEXT, tokens_in INTEGER, tokens_out INTEGER, cache_writes INTEGER, cache_reads INTEGER, cost INTEGER)") conn.execute("CREATE TABLE messages (id TEXT PRIMARY KEY, task_id TEXT, role TEXT, content TEXT, model_id TEXT, started_at INTEGER, finished_at INTEGER, tokens_in INTEGER, tokens_out INTEGER, cache_writes INTEGER, cache_reads INTEGER, cost INTEGER)") conn.execute("INSERT INTO tasks VALUES ('kodu-task', 1000, 3000, 'kodu title', '/tmp/kodu', 1, 2, 3, 4, 5)") conn.execute("INSERT INTO messages VALUES ('m1', 'kodu-task', 'user', '[{\"type\":\"text\",\"text\":\"kodu prompt\"}]', 'claude', 1100, 1200, 1, 0, 0, 0, 0)") conn.execute("INSERT INTO messages VALUES ('m2', 'kodu-task', 'assistant', 'answer', 'claude', 1300, 1400, 0, 2, 0, 0, 5)") cursor_dir = tmp_path / ".cursor" / "chats" / "hash" / "cursor-session" cursor_dir.mkdir(parents=True) with sqlite3.connect(cursor_dir / "store.db") as conn: conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)") conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)") conn.execute("INSERT INTO blobs VALUES ('b1', ?)", (json.dumps({"role": "user", "content": "<user_info>metadata</user_info>"}).encode(),)) conn.execute("INSERT INTO blobs VALUES ('b2', ?)", (json.dumps({"role": "user", "content": [{"type": "text", "text": "<user_query>\ncursor prompt\n</user_query>"}]}).encode(),)) (tmp_path / ".cursor" / "prompt_history.json").write_text(json.dumps(["cursor prompt"])) aider = tmp_path / "local-workspaces" / "repo" aider.mkdir(parents=True) (aider / ".aider.chat.history.md").write_text("# aider chat started at 2026-06-10 00:00:00\n\n#### aider prompt\n\nassistant reply\n") kiro = tmp_path / ".kiro" / "sessions" / "cli" kiro.mkdir(parents=True) (kiro / "kiro-session.json").write_text( json.dumps( { "session_id": "kiro-session", "cwd": "/tmp/kiro", "session_state": { "rts_model_state": {"model_info": {"model_id": "claude-sonnet-4-5"}}, "conversation_metadata": {"user_turn_metadatas": [{"message_ids": ["prompt-1", "assistant-1"]}]}, }, } ) ) _write_jsonl( kiro / "kiro-session.jsonl", [ {"version": "v1", "kind": "Prompt", "data": {"message_id": "prompt-1", "content": [{"kind": "text", "data": "kiro prompt"}], "meta": {"timestamp": 1770983426.42}}}, {"version": "v1", "kind": "AssistantMessage", "data": {"message_id": "assistant-1", "content": [{"kind": "text", "data": "kiro answer"}]}}, ], ) sessions = {(item.platform, item.id): item for item in scanners.scan(scanners.DEFAULT_PLATFORMS, (), 4)} assert sessions[("kodu", "kodu-task")].first_user_message == "kodu prompt" assert sessions[("cursor-cli", "cursor-session")].first_user_message == "cursor prompt" assert sessions[("aider", "repo-2026-06-10-00-00-00")].first_user_message == "aider prompt" assert sessions[("kiro", "kiro-session")].first_user_message == "kiro prompt" assert sessions[("kiro", "kiro-session")].model == "claude-sonnet-4-5" def test_vscode_extension_scanners_include_windows_appdata_roots(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) roo_history = ( tmp_path / "AppData" / "Roaming" / "Code" / "User" / "globalStorage" / "rooveterinaryinc.roo-cline" / "tasks" / "roo-task" / "api_conversation_history.json" ) roo_history.parent.mkdir(parents=True) roo_history.write_text(json.dumps([{"role": "user", "content": [{"type": "text", "text": "windows roo prompt"}]}])) kodu_dir = ( tmp_path / "AppData" / "Roaming" / "Code" / "User" / "globalStorage" / "kodu-ai.claude-dev-experimental" / "db" ) kodu_dir.mkdir(parents=True) with sqlite3.connect(kodu_dir / "Azad.db") as conn: conn.execute("CREATE TABLE tasks (id TEXT PRIMARY KEY, created_at INTEGER, updated_at INTEGER, name TEXT, dir_absolute_path TEXT, tokens_in INTEGER, tokens_out INTEGER, cache_writes INTEGER, cache_reads INTEGER, cost INTEGER)") conn.execute("CREATE TABLE messages (id TEXT PRIMARY KEY, task_id TEXT, role TEXT, content TEXT, model_id TEXT, started_at INTEGER, finished_at INTEGER, tokens_in INTEGER, tokens_out INTEGER, cache_writes INTEGER, cache_reads INTEGER, cost INTEGER)") conn.execute("INSERT INTO tasks VALUES ('kodu-windows', 1000, 3000, 'kodu title', 'C:/repo', 1, 2, 3, 4, 5)") conn.execute("INSERT INTO messages VALUES ('m1', 'kodu-windows', 'user', '[{\"type\":\"text\",\"text\":\"windows kodu prompt\"}]', 'claude', 1100, 1200, 1, 0, 0, 0, 0)") roo_sessions = scanners.scan(frozenset({"roo-code"}), (), 4) kodu_sessions = scan_kodu((), 4) assert [item.first_user_message for item in roo_sessions] == ["windows roo prompt"] assert [item.first_user_message for item in kodu_sessions] == ["windows kodu prompt"] def test_file_uri_path_preserves_windows_drive_and_unc_paths() -> None: assert _file_uri_path("file:///tmp/amp") == "/tmp/amp" assert _file_uri_path("file:///C:/Users/yeongyu/project") == "C:/Users/yeongyu/project" assert _file_uri_path("file://server/share/project") == "//server/share/project" def test_kiro_scanner_skips_metadata_only_sessions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) kiro = tmp_path / ".kiro" / "sessions" / "cli" kiro.mkdir(parents=True) (kiro / "metadata-only.json").write_text( json.dumps( { "session_id": "metadata-only", "cwd": "/tmp/kiro", "session_state": { "rts_model_state": {"model_info": {"model_id": "claude-sonnet-4-5"}}, "conversation_metadata": {"user_turn_metadatas": [{"message_ids": ["prompt-1"]}]}, }, } ) ) sessions = scanners.scan(frozenset({"kiro"}), (), 4) assert ("kiro", "metadata-only") not in {(item.platform, item.id) for item in sessions} def test_get_payload_reconstructs_events_for_non_jsonl_sessions() -> None: session = Session( "amp", "T-amp-1", "/tmp/T-amp-1.json", "/tmp/amp", "2026-06-10T00:00:00+00:00", "2026-06-10T00:00:01+00:00", None, "claude-opus-4-6", "first prompt", {}, last_user_message="last prompt", ) payload = _get_payload([session], ["T-amp-1"]) result = payload["results"][0] assert isinstance(result, dict) assert result["events"] == [ {"type": "message", "message": {"role": "user", "content": "first prompt"}}, {"type": "message", "message": {"role": "user", "content": "last prompt"}}, ] -
test_optional_sqlite_scanners.py 3.4 KB
# /// script # requires-python = ">=3.11" # dependencies = ["pytest"] # /// # --- How to run --- # uv run --with pytest pytest scripts/tests/test_optional_sqlite_scanners.py -v # pyright: reportImplicitRelativeImport=false, reportMissingImports=false from __future__ import annotations import json import sqlite3 import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from agent_sessions.sqlite_optional_scanners import scan_crush, scan_goose, scan_hermes, scan_kilo_cli, scan_zed def _message_db(path: Path, table: str, prompt: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) with sqlite3.connect(path) as conn: _ = conn.execute(f"CREATE TABLE {table} (session_id TEXT, role TEXT, data TEXT, created_at INTEGER)") _ = conn.execute( f"INSERT INTO {table} VALUES ('s1', 'user', ?, 1770983426420)", (json.dumps({"role": "user", "content": [{"type": "text", "text": prompt}]}),), ) _ = conn.execute( f"INSERT INTO {table} VALUES ('s1', 'assistant', ?, 1770983427420)", (json.dumps({"role": "assistant", "modelID": "claude-sonnet-4-5", "providerID": "anthropic", "content": "answer"}),), ) def test_optional_sqlite_scanners_reconstruct_present_prompt_stores(tmp_path: Path) -> None: _message_db(tmp_path / "kilo" / "kilo.db", "message", "kilo prompt") _message_db(tmp_path / "hermes" / "state.db", "messages", "hermes prompt") _message_db(tmp_path / "goose" / "sessions.db", "messages", "goose prompt") (tmp_path / "crush").mkdir() with sqlite3.connect(tmp_path / "crush" / "crush.db") as conn: _ = conn.execute("CREATE TABLE messages (session_id TEXT, role TEXT, parts TEXT, created_at INTEGER)") _ = conn.execute("INSERT INTO messages VALUES ('s1', 'user', ?, 1770983426420)", (json.dumps([{"type": "text", "text": "crush prompt"}]),)) (tmp_path / "zed").mkdir() with sqlite3.connect(tmp_path / "zed" / "threads.db") as conn: _ = conn.execute("CREATE TABLE threads (id TEXT, data_type TEXT, data BLOB, updated_at TEXT)") _ = conn.execute( "INSERT INTO threads VALUES ('t1', 'json', ?, '2026-06-10T00:00:00Z')", (json.dumps({"model": {"provider": "zed.dev", "model": "claude"}, "messages": [{"role": "user", "content": "zed prompt"}]}).encode(),), ) sessions = [ *scan_kilo_cli((tmp_path / "kilo",), 4), *scan_hermes((tmp_path / "hermes",), 4), *scan_goose((tmp_path / "goose",), 4), *scan_crush((tmp_path / "crush",), 4), *scan_zed((tmp_path / "zed",), 4), ] prompts = {item.platform: item.first_user_message for item in sessions} assert prompts == { "kilo-cli": "kilo prompt", "hermes": "hermes prompt", "goose": "goose prompt", "crush": "crush prompt", "zed": "zed prompt", } def test_optional_sqlite_scanners_skip_unsupported_schemas(tmp_path: Path) -> None: for name in ("kilo.db", "state.db", "sessions.db", "crush.db", "threads.db"): with sqlite3.connect(tmp_path / name) as conn: _ = conn.execute("CREATE TABLE unrelated (id TEXT)") assert scan_kilo_cli((tmp_path,), 4) == [] assert scan_hermes((tmp_path,), 4) == [] assert scan_goose((tmp_path,), 4) == [] assert scan_crush((tmp_path,), 4) == [] assert scan_zed((tmp_path,), 4) == [] -
test_pi_family_scanners.py 6.8 KB
# /// script # requires-python = ">=3.11" # dependencies = ["pytest"] # /// # --- How to run --- # uv run --with pytest pytest scripts/tests/test_pi_family_scanners.py -v # pyright: reportImplicitRelativeImport=false, reportMissingImports=false from __future__ import annotations import json import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from agent_sessions import scanners from agent_sessions.types import JsonMap, Session PI_FAMILY = frozenset({"senpi", "oh-my-pi", "gajae-code"}) def _write_session(path: Path, session_id: str, cwd: str, prompt: str, agent: str | None = None) -> None: rows: list[JsonMap] = [ {"type": "session", "version": 3, "id": session_id, "timestamp": "2026-07-20T00:00:00.000Z", "cwd": cwd}, {"type": "model_change", "id": "m1", "timestamp": "2026-07-20T00:00:00.100Z", "model": "anthropic/claude-fable-5"}, { "type": "message", "id": "e1", "timestamp": "2026-07-20T00:00:01.000Z", "message": {"role": "user", "content": [{"type": "text", "text": prompt}]}, }, ] if agent is not None: rows.insert( 1, { "type": "session_meta", "timestamp": "2026-07-20T00:00:00.050Z", "payload": {"id": session_id, "source": {"subagent": agent}}, }, ) path.parent.mkdir(parents=True, exist_ok=True) _ = path.write_text("\n".join(json.dumps(row) for row in rows) + "\n") def _by_platform(items: list[Session]) -> dict[str, Session]: return {item.platform: item for item in items} def test_pi_family_platforms_and_aliases_are_registered() -> None: assert PI_FAMILY <= scanners.DEFAULT_PLATFORMS assert scanners.PLATFORM_ALIASES["omp"] == "oh-my-pi" assert scanners.PLATFORM_ALIASES["ohmypi"] == "oh-my-pi" assert scanners.PLATFORM_ALIASES["gjc"] == "gajae-code" assert scanners.PLATFORM_ALIASES["gajae"] == "gajae-code" def test_pi_family_scanners_read_each_product_home_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.delenv("XDG_DATA_HOME", raising=False) _write_session( tmp_path / ".senpi" / "agent" / "sessions" / "-tmp-senpi" / "2026-07-20T00-00-00-000Z_senpi-1.jsonl", "senpi-1", "/tmp/senpi", "senpi prompt", ) _write_session( tmp_path / ".omp" / "agent" / "sessions" / "-tmp-omp" / "2026-07-20T00-00-00-000Z_omp-1.jsonl", "omp-1", "/tmp/omp", "oh-my-pi prompt", ) _write_session( tmp_path / ".gjc" / "agent" / "sessions" / "-tmp-gjc" / "2026-07-20T00-00-00-000Z_gjc-1.jsonl", "gjc-1", "/tmp/gjc", "gajae-code prompt", ) sessions = _by_platform(scanners.scan(PI_FAMILY, (), 4)) assert sessions["senpi"].first_user_message == "senpi prompt" assert sessions["oh-my-pi"].id == "omp-1" assert sessions["oh-my-pi"].first_user_message == "oh-my-pi prompt" assert sessions["oh-my-pi"].cwd == "/tmp/omp" assert sessions["oh-my-pi"].model == "anthropic/claude-fable-5" assert sessions["gajae-code"].id == "gjc-1" assert sessions["gajae-code"].first_user_message == "gajae-code prompt" def test_senpi_scanner_reads_omo_and_legacy_home_stores(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.delenv("XDG_DATA_HOME", raising=False) _write_session( tmp_path / ".omo" / "agent" / "sessions" / "-tmp-omo" / "2026-07-20T00-00-00-000Z_omo-current.jsonl", "omo-current", "/tmp/omo", "omo current prompt", ) _write_session( tmp_path / ".senpi" / "agent" / "sessions" / "-tmp-senpi" / "2026-07-20T00-00-00-000Z_senpi-legacy.jsonl", "senpi-legacy", "/tmp/senpi", "senpi legacy prompt", ) sessions = {item.id: item for item in scanners.scan(frozenset({"senpi"}), (), 4)} assert set(sessions) == {"omo-current", "senpi-legacy"} assert sessions["omo-current"].platform == "senpi" assert sessions["omo-current"].cwd == "/tmp/omo" assert sessions["senpi-legacy"].first_user_message == "senpi legacy prompt" def test_senpi_scanner_deduplicates_overlapping_omo_and_legacy_sessions( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.delenv("XDG_DATA_HOME", raising=False) _write_session( tmp_path / ".omo" / "agent" / "sessions" / "-tmp-overlap" / "2026-07-20T00-00-00-000Z_shared.jsonl", "shared", "/tmp/omo", "omo linked prompt", agent="sisyphus", ) _write_session( tmp_path / ".senpi" / "agent" / "sessions" / "-tmp-overlap" / "2026-07-20T00-00-00-000Z_shared.jsonl", "shared", "/tmp/senpi", "legacy prompt", ) sessions = scanners.scan(frozenset({"senpi"}), (), 4) assert len(sessions) == 1 assert sessions[0].id == "shared" assert sessions[0].agent == "sisyphus" assert sessions[0].cwd == "/tmp/omo" def test_pi_family_scanners_cover_profile_and_xdg_roots(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-data")) _write_session( tmp_path / ".omp" / "profiles" / "work" / "agent" / "sessions" / "-repo" / "2026-07-20T00-00-00-000Z_omp-work.jsonl", "omp-work", "/repo", "omp profile prompt", ) _write_session( tmp_path / "xdg-data" / "gjc" / "sessions" / "-repo" / "2026-07-20T00-00-00-000Z_gjc-xdg.jsonl", "gjc-xdg", "/repo", "gjc xdg prompt", ) _write_session( tmp_path / "xdg-data" / "omp" / "profiles" / "work" / "sessions" / "-repo" / "2026-07-20T00-00-00-000Z_omp-xdg.jsonl", "omp-xdg", "/repo", "omp xdg profile prompt", ) found = {(item.platform, item.id) for item in scanners.scan(PI_FAMILY, (), 4)} assert ("oh-my-pi", "omp-work") in found assert ("gajae-code", "gjc-xdg") in found assert ("oh-my-pi", "omp-xdg") in found def test_pi_family_stores_do_not_leak_across_platform_keys(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.delenv("XDG_DATA_HOME", raising=False) _write_session( tmp_path / ".omp" / "agent" / "sessions" / "-tmp-omp" / "2026-07-20T00-00-00-000Z_omp-only.jsonl", "omp-only", "/tmp/omp", "oh-my-pi prompt", ) senpi_sessions = scanners.scan(frozenset({"senpi"}), (), 4) omp_sessions = scanners.scan(frozenset({"omp"}), (), 4) assert senpi_sessions == [] assert [(item.platform, item.id) for item in omp_sessions] == [("oh-my-pi", "omp-only")]
-
-
find-agent-sessions.py 514 B
#!/usr/bin/env python3 # /// script # requires-python = ">=3.11" # /// # --- How to run --- # python3 scripts/find-agent-sessions.py list --limit 20 # python3 scripts/find-agent-sessions.py search "commit" --from 7d # python3 scripts/find-agent-sessions.py get <session-id> from __future__ import annotations import sys import runpy from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) if __name__ == "__main__": _ = runpy.run_module("agent_sessions.cli", run_name="__main__")
-
-
.gitignore 90 B · in bundle
-
.npmignore 131 B · in bundle
-
AGENTS.md 3.9 KB
# coding-agent-sessions — Cross-Platform Session Finder (Python) **Generated:** 2026-08-24 (f3642fcda) ## OVERVIEW The only shared skill besides `ultimate-browsing` carrying a real sub-project: a Python package + CLI that finds, lists, searches, and reads local coding-agent session transcripts across ~25 platforms (Codex, Claude, OpenCode, OMO/Senpi/pi, and a long tail), normalizing them into one `Session` model. Earned this file: 33 files, 23 Python modules, own test suite, own `pyrightconfig.json`, own `.npmignore` (ships in the npm package minus tests/caches). ## STRUCTURE ``` coding-agent-sessions/ ├── SKILL.md # router: platform table → references/<platform>.md ├── references/ # codex, claude, opencode, senpi, all-platforms (store layouts per platform) ├── agents/openai.yaml # Codex agent role declaration ├── scripts/ │ ├── find-agent-sessions.py # thin shebang entry (PEP 723, >=3.11); runpy → agent_sessions.cli │ ├── agent_sessions/ # the package (12 modules) │ └── tests/ # 6 pytest modules └── pyrightconfig.json # extraPaths scripts/, excludes scripts/tests ``` ## PUBLIC API (`scripts/agent_sessions/`) | Module | Key exports | |--------|-------------| | `scanners.py` | `scan`, `DEFAULT_PLATFORMS`, `PLATFORM_SCANNERS`, per-platform `scan_*` (codex, claude, opencode, senpi, oh_my_pi, gajae_code, …) | | `types.py` | `Session`, `Options`, `Json`, `JsonMap` — imported by nearly every module and test | | `transcript.py` | parallel file reads, normalization, timestamps, session ids; `recent`, `MAX_PLATFORM_FILES` | | `cli.py` | command parsing, filtering, payload construction, JSON emission (283 LOC — behavioral hotspot) | | platform adapters | `file_scanners.py` (file-backed providers), `opencode.py` (CLI/SQLite/storage fallbacks), `sqlite_scanners.py`, `sqlite_optional_scanners.py`, `pi_family.py`, `aside_scanner.py`, `kiro_scanner.py` | ## CLI CONTRACT `python3 scripts/find-agent-sessions.py <command>`; aliases `find`=`search`, `read`=`get`. Commands: `list`, `find`/`search` (repeated `--query` lanes, `--from 7d`, repeated `--platform`, `--workers`, `--cwd/--model`, `--include-subagents`, `--limit`), `get`/`read <session-id>`. Output is JSON with `match_reasons` and reconstructed first/last user prompts — that JSON shape is the stable contract. ## CONVENTIONS - Stdlib-only Python (`>=3.11`), `from __future__ import annotations`, pathlib everywhere; no third-party deps at runtime. - Probe-first, bounded discovery: platform-specific roots checked before broad globbing; optional platforms excluded from default search when they cannot reconstruct user prompts. - Main sessions hide subagent children by default but report child counts; `--include-subagents` flips it. Parent/child linkage preserved and records deduplicated. - Tests are pytest with `tmp_path` + `MonkeyPatch` isolation; in-file `# pyright: ignore[reportMissingImports]`-style relaxations for import diagnostics only. - `.npmignore` strips `scripts/tests/`, `pyrightconfig.json`, and caches from the shipped skill. ## ANTI-PATTERNS - NEVER answer from normalized previews alone — pull the raw transcript (`read`/`get`) for exact evidence. - Usage-only stores are NOT transcript sources. - A Claude subagent's embedded `sessionId` is NOT its identity — use its `agentId` + parent-directory linkage. - Don't add a platform by globbing blindly; register it in `PLATFORM_SCANNERS`/`DEFAULT_PLATFORMS` with a bounded root probe. ## COMMANDS ```bash # from packages/shared-skills/skills/coding-agent-sessions/ python3 scripts/find-agent-sessions.py list --limit 20 python3 scripts/find-agent-sessions.py find "commit" --from 7d --platform senpi --platform opencode python3 scripts/find-agent-sessions.py read <session-id> pytest scripts/tests ``` - Parent: [`packages/shared-skills/AGENTS.md`](../../AGENTS.md). -
pyrightconfig.json 157 B
{ "extraPaths": ["scripts"], "include": ["scripts/agent_sessions", "scripts/find-agent-sessions.py"], "exclude": ["**/__pycache__", "scripts/tests"] } -
SKILL.md 10.7 KB
--- name: coding-agent-sessions description: "Finds, reads, and reconstructs coding-agent sessions across Codex, Claude, OpenCode, OMO/Senpi, and other local agent logs. Use when asked to find or search past sessions, transcripts, or subagent runs, or to recover what an earlier session did." --- # Coding Agent Sessions Find local coding-agent sessions across agent products before answering from memory. Prefer the bundled finder for broad cross-platform search, then read the selected session or raw file when you need exact evidence. ## PHASE 0 - PLATFORM ROUTER 1. **IF the user names a platform, load its reference first.** | Platform | Read | |---|---| | Codex / OpenAI Codex CLI | `references/codex.md` | | Claude Code / Claude Desktop histories | `references/claude.md` | | OMO / Senpi / pi coding-agent logs | `references/senpi.md` | | oh-my-pi (`omp`, `~/.omp`) and gajae-code (`gjc`, `~/.gjc`) logs | `references/senpi.md` | | OpenCode / oh-my-openagent (formerly oh-my-opencode) storage | `references/opencode.md` | | OpenClaw, Droid, Amp, Gemini, Kimi, Qwen, Codebuff, Roo/Kilo/Cline, Kodu, Cursor CLI, Aider, Kiro, Goose, Hermes, Crush, Zed, Aside | `references/all-platforms.md` | | Unknown / "any session" / cross-agent search | `references/all-platforms.md` | 2. **Run the broad finder first unless the user gave an exact file path. For fuzzy recall, expand the query first.** When the user remembers a task vaguely ("that OpenCode bug", "the dashboard PR", "when did we fix X"), derive 3-6 short query lanes before searching: product/tool aliases, repo/package names, exact error text, issue/PR/session IDs, English/Korean phrasing, and likely verbs such as `fix`, `review`, `plan`, `deploy`, or `merge`. Run the lanes together with repeated `--query` so `match_reasons` shows which wording found the hit. ```bash python3 scripts/find-agent-sessions.py list --limit 20 python3 scripts/find-agent-sessions.py find "commit" --from 7d --platform senpi --platform opencode python3 scripts/find-agent-sessions.py find "proxy" --platform openclaw --platform droid --platform amp python3 scripts/find-agent-sessions.py find "refactor" --platform oh-my-pi --platform gajae-code python3 scripts/find-agent-sessions.py find --query "deploy" --query "token usage" --workers 64 python3 scripts/find-agent-sessions.py find --query "opencode bug" --query "fix opencode" --query "OpenCode parent session" --include-subagents --workers 64 python3 scripts/find-agent-sessions.py read <session-id> ``` Use `python` instead of `python3` on systems where that is the available executable. 3. **Use explorer-style parallel lanes when one query batch is not enough.** If scope is broad (multi-month, many repos/platforms, or a vague "what happened with X"), split independent searches by names/errors, repos/cwds, platforms/models, and time windows. Use available subagent/delegation tools for these lanes when they exist; otherwise run the finder calls in parallel. Merge candidates by `id`/`path`, then read the most likely sessions. 4. **Read details from search results before ad hoc digging.** Search results include `detail_hint`; run that `read <session-id> --platform <platform>` command to see the first user prompt, last user prompt, events, and child sessions together. 5. **Verify by opening raw transcripts for claims.** The finder normalizes formats; the raw `path` remains the source of truth. ## Output Contract The finder prints JSON for stdout and `jq`. Every result includes: | Field | Meaning | |---|---| | `platform` | Registered platform key such as `codex`, `claude`, `senpi`, `oh-my-pi`, `gajae-code`, `opencode`, `openclaw`, `droid`, `amp`, `kodu`, `cursor-cli`, `aider`, `roo-code`, `kilo-code`, `kilo-cli`, `kiro`, or `aside` | | `id` | Session ID or stable file-derived ID | | `path` | Raw transcript/index file | | `cwd` | Working directory when recoverable | | `created_at`, `updated_at` | ISO-like timestamps when recoverable | | `provider`, `model` | Model metadata when recoverable | | `first_user_message` | First user prompt preview (for subagents: task description + delegated prompt) | | `last_user_message` | Last user prompt preview when recoverable | | `usage` | Token/cost clues when present in the platform log | | `parent_id` | Parent session/thread ID when this is a subagent or child session, else `null` | | `agent` | Subagent label (Claude `agentType`, Codex `nickname (role)`, OpenCode agent name) | | `subagent_count` | Number of child sessions spawned by this session | | `detail_hint` | Ready-to-run `read` command for detailed inspection | | `match_reasons` | Search-only array explaining which field/content matched each query | Each `match_reasons` entry includes `query`, `platform`, `field`, and `snippet`, so you can tell which platform matched and what content caused the hit without opening every transcript. ## Filters `list` and `search` share these filters: | Filter | Meaning | |---|---| | `--platform` | Repeatable platform filter; pass one platform per flag | | `--root` | Extra root to scan, repeatable | | `--from`, `--to` | Date bounds: `YYYY-MM-DD`, `YYYY-MM`, `YYYY`, `today`, `yesterday`, `7d` | | `--cwd` | Working-directory substring | | `--model` | Model substring | | `--limit` | Maximum results | | `--query` | Repeatable search query; multiple queries return per-query groups plus a de-duplicated merged result list | | `--workers` | Parallel worker count for platform scans, transcript parsing, OpenCode message joins, and multi-query matching | | `--include-subagents` | Include subagent/child sessions as standalone `list`/`search` results (hidden by default) | When `--platform` is omitted, the finder searches every registered platform in parallel. Each optional platform first probes fixed, known transcript roots and returns immediately when the product has no local store, so broad default searches stay cheap. Use repeatable flags such as `--platform openclaw --platform droid` only when narrowing. Comma-separated platform values are intentionally unsupported. For OpenCode, the finder uses `opencode db path` plus direct SQLite queries first, then `opencode session list --format json` as a fallback. It avoids heavy `messages/` or `parts/` scans during normal list/search, and only falls back to file joins when the OpenCode DB/CLI is unavailable or explicit `--root` values request a nonstandard store. Usage-only sources such as Copilot OTEL, Mux, Antigravity tokscale cache rows, Synthetic provider retagging, and Cursor IDE usage CSV are excluded from default transcript search because they do not reconstruct user prompts. ## Subagent / Child Sessions `list` and `find`/`search` return main sessions only by default, each annotated with `subagent_count`. `read <main-session-id>` (alias: `get`) always returns a `prompts` object and a `subagents` array containing every child session (id, agent label, parent_id, prompt preview, raw path), so opening a main session reveals its whole delegation tree. `read <child-id>` works too and returns that child's own events. | Platform | Where children live | Linkage | |---|---|---| | Claude Code | `projects/<proj>/<session-id>/subagents/agent-*.jsonl` (Task tool) and `.../subagents/workflows/wf_*/agent-*.jsonl` (Workflow) | Directory name = parent session ID; `agent-*.meta.json` holds `agentType` + task description | | Codex | Regular threads in `state_*.sqlite` + own rollout JSONL | `thread_spawn_edges` table and `threads.source` / rollout `session_meta.payload.source.subagent.thread_spawn` | | OpenCode | Regular sessions in `opencode.db` / `storage/session/` | `session.parent_id` column / `parentID` field; `agent` column names the subagent | When the user asks whether some specific work was ever done, search with `--include-subagents` — delegated work often lives only in child transcripts, not in the main session. Workflow `journal.jsonl` files are orchestration logs, not sessions. ## Codex Notes For Codex sessions, use the same broad finder. It reads `state_*.sqlite`, rollout JSONL, and archived rollout files: ```bash python3 scripts/find-agent-sessions.py list --platform codex --from 7d python3 scripts/find-agent-sessions.py find "deploy" --platform codex python3 scripts/find-agent-sessions.py read <session-id> --platform codex ``` Use `references/codex.md` for Codex storage details. ## Troubleshooting | Problem | Fix | |---|---| | Missing Codex sessions | Set `CODEX_HOME` or pass `--root /path/to/.codex`. | | Missing oh-my-pi / gajae-code sessions | Those stores live in `~/.omp/agent/sessions` and `~/.gjc/agent/sessions`. For a custom `PI_CONFIG_DIR` / `PI_CODING_AGENT_DIR`, pass that agent dir with `--root`. | | Missing OMO / Senpi sessions | The `senpi` platform searches `~/.omo/agent/sessions`, `~/.senpi/agent/sessions`, and `~/.pi/agent/sessions` (plus matching profile roots). Pass a nonstandard agent directory with `--root`. | | Missing OpenCode sessions | Pass the data dir that contains `messages/` and `parts/`, often `~/.opencode` or `~/.local/share/opencode`. | | Missing Claude sessions | Search `~/.claude/projects`, `~/.claude/transcripts`, and `~/.claude/pre-compact-session-histories`; use `--root` for nonstandard config dirs. | | Missing Aside sessions | The Aside browser agent stores per-user data under `~/.aside/u/<n>/` (`sessions/<date>_<id>/messages.jsonl` transcripts + a `state.db` index; `agents/*/sessions/` is just a hardlink mirror). Pass `--root` for a nonstandard `.aside` dir or an exported user dir. | | Missing optional platform sessions | Check `references/all-platforms.md` for the exact local store. For project-local tools such as Aider, pass `--root /path/to/workspace` if the repo is outside the bounded default roots. | | Date filter misses local sessions | Timestamps are compared as UTC instants when parseable; otherwise file mtime is used. | | Search is slow | Narrow with repeated `--platform` flags or date/cwd filters. Optional stores are probed before parsing; avoid passing your whole home as `--root` unless you really want every bounded project-local scan. | ## Activation Use this skill for any request to find, read, or inspect a local coding-agent session, regardless of product name — including memory-recall questions ("what did I work on a few days ago", "did we already migrate X", "when did I fix Y"). If the user only says "that session where we did X", do not rely on one literal query: expand to multiple discriminative terms first, add `--include-subagents` when the work may have been delegated, then narrow by `--platform`, `cwd`, `model`, and time (`--from 7d` for "a few days ago"). Prefer the `detail_hint` from the chosen search result for the next read step. If the first query batch is still ambiguous, use explorer-style lanes by keyword, repo/cwd, platform/model, and time window before summarizing.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.