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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Shepherd は、Herdr で動く coding agent の状態を記録する daemon ベ

Herdr の `herdr agent read` は terminal stream や scrollback を読みます。Shepherd は agent の session data から作業状況、message の抜粋、compact tool result、未読の outcome を取得します。Shepherd は agent を操作しません。agent の start、prompt、wait、pane 操作、terminal control には公式 Herdr CLI または skill を使います。

現在は Claude Code、Codex、Gemini CLI、OpenCode、Pi のセッション履歴の取得に対応しています。
現在は Antigravity、Claude Code、Codex、Gemini CLI、OpenCode、Pi のセッション履歴の取得に対応しています。

## 要件

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Shepherd is a daemon-backed observability layer for coding agents running in Her

Herdr's `herdr agent read` reads terminal streams or scrollback. Shepherd instead reads agent session data so callers can retrieve work status, structured message excerpts, compact tool results, and unread outcomes without parsing terminal output. Shepherd is read-only; use the official Herdr CLI or skill for agent start, prompts, waits, pane operations, and terminal control.

Shepherd currently supports session history from Claude Code, Codex, Gemini CLI, OpenCode, and Pi.
Shepherd currently supports session history from Antigravity, Claude Code, Codex, Gemini CLI, OpenCode, and Pi.

## Requirements

Expand Down
217 changes: 217 additions & 0 deletions src/agent-history/agy-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { join } from "node:path";
import type {
AgentHistoryMessage,
AgentHistoryRef,
CompactAgentHistory,
} from "@/observability/contracts.js";
import {
type AgentHistoryReader,
compactFromMessages,
limitMessages,
readJsonl,
} from "./readers.js";
import { messageRef, textFromContent, timestampFrom } from "./text.js";
import { compactToolResult } from "./tool-compaction.js";

const ignoredEntryTypes = new Set([
"attachment",
"checkpoint",
"conversation_history",
"ephemeral_message",
"file-history-snapshot",
"mode",
"permission-mode",
"system_message",
]);

const knownToolExecutionTypes = new Set([
"ask_question",
"call_mcp_tool",
"command_output",
"edit_file",
"error_message",
"execute_command",
"find_by_name",
"generate_image",
"grep_search",
"list_dir",
"list_directory",
"list_resources",
"read_resource",
"read_url_content",
"replace_file_content",
"run_command",
"search_web",
"tool_call_result",
"tool_result",
"view_file",
"write_file",
"write_to_file",
]);

function isKnownToolExecutionType(type: string): boolean {
return knownToolExecutionTypes.has(type.toLowerCase());
}

export class AgyHistoryReader implements AgentHistoryReader {
private readonly homeDir?: string;

constructor(options: { homeDir?: string } = {}) {
if (options.homeDir !== undefined) {
this.homeDir = options.homeDir;
}
}

canRead(ref: AgentHistoryRef): boolean {
return ref.source === "agy-jsonl" && Boolean(ref.path ?? ref.value);
}

async read(
ref: AgentHistoryRef,
options: { homeDir?: string; limit?: number } = {},
): Promise<AgentHistoryMessage[]> {
const homeDir = options.homeDir ?? this.homeDir;
const path = resolveAgyTranscriptPath(ref, homeDir !== undefined ? { homeDir } : {});
if (!path) return [];

let entries: ReturnType<typeof readJsonl> extends Promise<infer T> ? T : never;
try {
entries = await readJsonl(path);
} catch {
return [];
}

const messages: AgentHistoryMessage[] = [];
const pendingToolCalls: string[] = [];

for (const entry of entries) {
const type = stringValue(entry.value.type);
const source = stringValue(entry.value.source);
const id =
stringValue(entry.value.id) ??
(typeof entry.value.step_index === "number" || typeof entry.value.step_index === "string"
? String(entry.value.step_index)
: undefined);
const timestamp =
timestampFrom(entry.value.created_at) ?? timestampFrom(entry.value.timestamp);
const refValue = messageRef(path, id, entry.line);

if (!type || isIgnoredEntry(type, source)) {
continue;
}

if (type === "USER_INPUT") {
pendingToolCalls.length = 0;
const text = textFromContent(entry.value.content);
if (text) {
messages.push({ ref: refValue, role: "user", text, timestamp });
}
continue;
}

if (type === "PLANNER_RESPONSE") {
const text = textFromContent(entry.value.content);
if (text) {
messages.push({ ref: refValue, role: "assistant", text, timestamp });
}

const toolCalls = Array.isArray(entry.value.tool_calls) ? entry.value.tool_calls : [];
for (const call of toolCalls) {
const callRecord = record(call);
const name = stringValue(callRecord.name) ?? "unknown";
pendingToolCalls.push(name);
}
continue;
}

if (!isKnownToolExecutionType(type)) {
continue;
}

const toolName = pendingToolCalls.shift() ?? inferToolName(type, entry.value);

const text = textFromToolResult(entry.value);
if (text !== null) {
const isError = isToolResultError(entry.value);
const compact = compactToolResult({ isError, ref: refValue, text, toolName });
messages.push({
compact,
ref: refValue,
role: "tool_result",
text: compact.text,
timestamp,
toolName,
});
}
}

return limitMessages(messages, options.limit);
}

async readCompact(ref: AgentHistoryRef): Promise<CompactAgentHistory> {
return compactFromMessages(ref, await this.read(ref));
}
}

export function resolveAgyTranscriptPath(
ref: AgentHistoryRef,
options: { homeDir?: string } = {},
): string {
if (ref.path && ref.path.length > 0) {
return ref.path;
}
const value = ref.value;
if (!value) return "";
if (value.includes("/") || value.endsWith(".jsonl")) {
return value;
}
const homeDir = options.homeDir ?? process.env.HOME ?? "";
return join(
homeDir,
".gemini",
"antigravity-cli",
"brain",
value,
".system_generated",
"logs",
"transcript.jsonl",
);
}

function isIgnoredEntry(type: string, source: string | null): boolean {
if (source === "SYSTEM" && type !== "ERROR_MESSAGE") return true;
return ignoredEntryTypes.has(type.toLowerCase());
}

function inferToolName(type: string, entry: Record<string, unknown>): string {
const explicit =
stringValue(entry.tool_name) ?? stringValue(entry.name) ?? stringValue(entry.tool);
if (explicit) return explicit;
if (type === "ERROR_MESSAGE" || type === "GENERIC") return "unknown";
return type.toLowerCase();
}

function textFromToolResult(value: Record<string, unknown>): string | null {
const text = textFromContent(value.content);
if (text) return text;
if (typeof value.error === "string" && value.error.length > 0) return value.error;
if (value.error !== undefined && value.error !== null) return JSON.stringify(value.error);
if (typeof value.exit_code === "number") return `exit code ${value.exit_code}`;
return null;
}

function isToolResultError(value: Record<string, unknown>): boolean {
if (value.type === "ERROR_MESSAGE") return true;
if (stringValue(value.status) === "ERROR") return true;
if (typeof value.exit_code === "number" && value.exit_code !== 0) return true;
if (value.error !== undefined && value.error !== null) return true;
return false;
}

function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}

function stringValue(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
117 changes: 116 additions & 1 deletion src/agent-history/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { existsSync } from "node:fs";
import { readdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { fileURLToPath } from "node:url";
import type { AgentHistoryRef, AgentSessionRef } from "@/observability/contracts.js";

export type AgentHistoryLookupInput = {
Expand Down Expand Up @@ -41,6 +42,26 @@ export async function discoverAgentHistory(
const ref = discoverOpenCodeSession({ cwd, homeDir, sessionId: input.agentSession.value });
if (ref) return { ...ref, kind: "agent_session" };
}
if (source === "agy-jsonl") {
const path = join(
homeDir,
".gemini",
"antigravity-cli",
"brain",
input.agentSession.value,
".system_generated",
"logs",
"transcript.jsonl",
);
if (existsSync(path)) {
return {
kind: "agent_session",
path,
source: "agy-jsonl",
value: input.agentSession.value,
};
}
}
}

const agent = input.agent?.toLowerCase() ?? input.agentSession?.agent.toLowerCase() ?? "";
Expand All @@ -57,6 +78,16 @@ export async function discoverAgentHistory(
if (agent === "gemini") {
candidates.push(...(await scanGeminiRoot(join(homeDir, ".gemini", "tmp"))));
}
if (
agent === "agy" ||
agent === "antigravity" ||
agent.includes("antigravity") ||
agent.includes("agy")
) {
candidates.push(
...(await scanAgyRoot(join(homeDir, ".gemini", "antigravity-cli", "brain"), homeDir, cwd)),
);
}
if (agent === "opencode") {
const ref = discoverOpenCodeSession({ cwd, homeDir, sessionId: null });
if (ref) return ref;
Expand All @@ -82,6 +113,13 @@ export function historySourceFromSessionRef(ref: AgentSessionRef): AgentHistoryR
if (agent === "codex" || source.includes("codex")) return "codex-jsonl";
if (agent === "opencode" || source.includes("opencode")) return "opencode-sqlite";
if (agent === "gemini" || source.includes("gemini")) return "gemini-json";
if (
agent === "agy" ||
agent === "antigravity" ||
source.includes("antigravity") ||
source.includes("agy")
)
return "agy-jsonl";
return "unknown";
}

Expand Down Expand Up @@ -150,6 +188,83 @@ async function scanGeminiRoot(root: string): Promise<Candidate[]> {
return candidates;
}

function normalizeWorkspacePath(uriOrPath: string): string {
try {
if (uriOrPath.startsWith("file://")) {
return resolve(fileURLToPath(uriOrPath));
}
} catch {
// fallback if fileURLToPath fails
}
const raw = uriOrPath.startsWith("file://") ? uriOrPath.slice(7) : uriOrPath;
return resolve(raw);
}

export function matchesWorkspaceUri(workspaceUri: string, cwd: string): boolean {
const ws = normalizeWorkspacePath(workspaceUri);
const target = normalizeWorkspacePath(cwd);
return ws === target;
}

function readAgyConversationSummaries(homeDir: string): Map<string, string[]> | null {
const dbPath = join(homeDir, ".gemini", "antigravity-cli", "conversation_summaries.db");
if (!existsSync(dbPath)) return null;
let sqlite: DatabaseSync | null = null;
try {
sqlite = new DatabaseSync(dbPath, { readOnly: true });
const rows = sqlite
.prepare("select conversation_id, workspace_uris from conversation_summaries")
.all() as Array<{ conversation_id: unknown; workspace_uris: unknown }>;
const summaries = new Map<string, string[]>();
for (const row of rows) {
if (typeof row.conversation_id === "string" && typeof row.workspace_uris === "string") {
try {
const parsed = JSON.parse(row.workspace_uris);
if (Array.isArray(parsed)) {
summaries.set(
row.conversation_id,
parsed.filter((u): u is string => typeof u === "string"),
);
}
} catch {
// ignore malformed JSON in individual row
}
}
}
return summaries;
} catch {
return null;
} finally {
sqlite?.close();
}
}

async function scanAgyRoot(
root: string,
homeDir: string,
cwd: string | null,
): Promise<Candidate[]> {
if (!existsSync(root) || !cwd) return [];
const summaries = readAgyConversationSummaries(homeDir);
if (summaries === null) return [];

const candidates: Candidate[] = [];
for (const [conversationId, workspaceUris] of summaries) {
const matches = workspaceUris.some((uri) => matchesWorkspaceUri(uri, cwd));
if (!matches) continue;
const path = join(root, conversationId, ".system_generated", "logs", "transcript.jsonl");
const stats = await stat(path).catch(() => null);
if (!stats?.isFile()) continue;
candidates.push({
cwd,
mtimeMs: stats.mtimeMs,
path,
source: "agy-jsonl",
});
}
return candidates;
}

async function listGeminiProjectDirs(root: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
const dirs: string[] = [];
Expand Down
Loading