Skip to content
Merged
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]

### Added
- **Codex Auto Review relationships.** Guardian rollouts now encode their

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required co-author trailer

The reviewed commit ends after its verification section and contains no Co-Authored-By: trailer, although this repository requires that trailer on every commit; recreate the proposed commit with a trailer naming the actual participants before it is merged.

AGENTS.md reference: AGENTS.md:L165-L166

Useful? React with 👍 / 👎.

original root session id in `providerVariant` as `auto-review:<session-id>`,
and `CodexSessionAdapter.autoReviewParentSessionID(providerVariant:)` exposes
the stable parse. Hosts can merge review runs into the originating session
without guessing from project names or timestamps; `parent_thread_id` is
intentionally not trusted when `session_id` names the root directly.
- **Scoped session search and directory filters.** Hosts can independently
search titles, user prompts, assistant replies, system prompts, and
tool/file operations. Summary pages and searches accept safe project-path
include/exclude filters, and related `providerVariant` rows can be hidden,
listed, and resolved back to an exact root session.
- **MCP client diagnostics and safe disconnects.**
`MCPSocketServer.clientConnections` reports a private connection id, the
kernel-reported peer pid when available, connection time, and last byte
Expand All @@ -25,6 +36,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
makes the claim below assertable rather than timed.

### Changed
- **Static session titles now mean a person spoke.** Codex user-role records
are passed through the same machine-context stripping used by live session
briefs, including `recommended_plugins`, AGENTS/skills/permissions blocks,
and environment context. A metadata-side title that is only injected
context is rejected too. AntiGravity no longer picks known system,
assistant, or tool steps as its fallback title; without a preserved user
prompt it uses an honest conversation-id fallback.
- **AntiGravity session-list metadata is bounded.** Listing a conversation no
longer decodes every `gen_metadata` protobuf or materializes the entire
`steps` table just to show its newest model and fallback title. SQLite counts
rows directly, only bounded first/recent turn windows are decoded, and only
the first twelve steps are read for title fallback. Full tables remain
available when the user explicitly opens the transcript.
- **Claude Cowork reports the working folder the user actually touched.**
Cowork's isolated `outputs` cwd is replaced, when structured tool paths are
available in the bounded transcript head, by their common external project
directory. The isolated path remains the safe fallback when no evidence is
present.
- **A desktop-sized MCP client budget, optional idle reclamation, and explicit
refusal.** The default cap is 64 rather than 16 because desktop clients may
keep one stdio bridge per open task; hosts can tune it. Idle expiry is now an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,108 @@ enum AntigravityLiveSQLite {
}

static func steps(at url: URL) -> [Step]? {
steps(at: url, limit: LiveSQLiteReader.maxRows)
}

/// Opening-list metadata needs only the first few steps for a fallback
/// title. Do not materialize a whole trajectory just to read one sentence.
static func firstSteps(at url: URL, limit: Int) -> [Step]? {
steps(at: url, limit: max(1, min(limit, LiveSQLiteReader.maxRows)))
}

private static func steps(at url: URL, limit: Int) -> [Step]? {
LiveSQLiteReader.read(at: url) { database in
let statement = try LiveSQLiteReader.prepare(
database,
"SELECT idx, step_type, step_payload FROM steps ORDER BY idx"
"SELECT idx, step_type, step_payload FROM steps ORDER BY idx LIMIT \(limit)"
)
defer { sqlite3_finalize(statement) }
var out: [Step] = []
var result = sqlite3_step(statement)
while result == SQLITE_ROW, out.count < LiveSQLiteReader.maxRows {
while result == SQLITE_ROW, out.count < limit {
out.append(Step(
idx: Int(sqlite3_column_int64(statement, 0)),
type: Int(sqlite3_column_int64(statement, 1)),
payload: LiveSQLiteReader.blob(statement, 2)
))
result = sqlite3_step(statement)
}
guard result == SQLITE_DONE || out.count >= LiveSQLiteReader.maxRows else {
guard result == SQLITE_DONE || out.count >= limit else {
throw LiveSQLiteReader.ReadError.statement
}
return out
}
}

struct MetadataSummary {
let firstDate: Date?
let lastDate: Date?
let model: String?
let messageCount: Int
}

/// The Sessions list needs four facts, not every decoded turn. Count rows
/// in SQLite, decode a bounded edge window for the first/last timestamps,
/// and search only recent turns for the model. Full turn decoding remains
/// in `turns(at:)` for the transcript opened by an explicit selection.
static func metadataSummary(at url: URL, edgeLimit: Int = 64) -> MetadataSummary? {
LiveSQLiteReader.read(at: url) { database in
let countStatement = try LiveSQLiteReader.prepare(
database,
"SELECT COUNT(*) FROM gen_metadata"
)
defer { sqlite3_finalize(countStatement) }
guard sqlite3_step(countStatement) == SQLITE_ROW else {
throw LiveSQLiteReader.ReadError.statement
}
let count = Int(sqlite3_column_int64(countStatement, 0))
guard count > 0 else {
return MetadataSummary(firstDate: nil, lastDate: nil, model: nil, messageCount: 0)
}

let limit = max(1, min(edgeLimit, LiveSQLiteReader.maxRows))
let first = try decodedTurns(
database: database,
order: "ASC",
limit: limit
)
let recent = try decodedTurns(
database: database,
order: "DESC",
limit: limit
)
return MetadataSummary(
firstDate: first.first?.date,
lastDate: recent.first?.date,
model: recent.lazy.compactMap { $0.model ?? $0.routedModel }.first,
messageCount: count
)
}
}

private static func decodedTurns(
database: OpaquePointer,
order: String,
limit: Int
) throws -> [AntigravityGenMetadataReader.Turn] {
let statement = try LiveSQLiteReader.prepare(
database,
"SELECT data FROM gen_metadata ORDER BY idx \(order) LIMIT \(limit)"
)
defer { sqlite3_finalize(statement) }
var out: [AntigravityGenMetadataReader.Turn] = []
var result = sqlite3_step(statement)
while result == SQLITE_ROW {
if let data = LiveSQLiteReader.blob(statement, 0),
let turn = AntigravityGenMetadataReader.decodeTurn(blob: data) {
out.append(turn)
}
result = sqlite3_step(statement)
}
guard result == SQLITE_DONE else { throw LiveSQLiteReader.ReadError.statement }
return out
}

/// `gen_metadata` decoded into model turns, keyed by row index.
///
/// `AntigravityGenMetadataReader.readGenMetadata` opens with `immutable=1`
Expand Down
37 changes: 14 additions & 23 deletions Sources/AgentSessionKit/Sessions/AntigravitySessionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,36 +114,23 @@ public struct AntigravitySessionAdapter: SessionProviderAdapter {
)
}

let turns = AntigravityLiveSQLite.turns(at: fileURL)
let dates = (turns ?? []).map(\.turn.date)
let firstText = hydrated?.title == nil ? firstUserishText(fileURL: fileURL) : nil
let metadata = AntigravityLiveSQLite.metadataSummary(at: fileURL)
let firstText = hydrated?.title == nil ? firstHumanPrompt(fileURL: fileURL) : nil

return summary(
sessionID: sessionID,
variant: variant,
model: Self.model(turns: turns),
model: metadata?.model,
title: hydrated?.title ?? firstText,
summaryText: firstText,
projectDir: hydrated?.projectDir,
createdAt: dates.first ?? SessionParsing.creationDate(fileURL),
lastActiveAt: dates.last ?? hydrated?.lastModified ?? SessionParsing.modificationDate(fileURL),
createdAt: metadata?.firstDate ?? SessionParsing.creationDate(fileURL),
lastActiveAt: metadata?.lastDate ?? hydrated?.lastModified ?? SessionParsing.modificationDate(fileURL),
fileURL: fileURL,
messageCount: turns.map(\.count) ?? SessionSummary.unknownMessageCount
messageCount: metadata?.messageCount ?? SessionSummary.unknownMessageCount
)
}

/// The most recent turn's model. `gen_metadata` is already decoded for
/// the dates and the message count, so no extra read happens here; the
/// router alias is the documented fallback when the model enum itself
/// has no learned label (see `AntigravityGenMetadataReader.Turn`).
static func model(turns: [(idx: Int, turn: AntigravityGenMetadataReader.Turn)]?) -> String? {
guard let turns else { return nil }
for entry in turns.reversed() {
if let model = entry.turn.model ?? entry.turn.routedModel { return model }
}
return nil
}

private func summary(
sessionID: String,
variant: String,
Expand Down Expand Up @@ -184,12 +171,16 @@ public struct AntigravitySessionAdapter: SessionProviderAdapter {
/// First readable prose in the opening steps, used only when no side
/// store had a title. Tool-argument JSON is skipped: it is real text,
/// but it is not what the conversation was about.
private func firstUserishText(fileURL: URL) -> String? {
guard let steps = AntigravityLiveSQLite.steps(at: fileURL) else { return nil }
for step in steps.prefix(Self.titleStepScanLimit) {
private func firstHumanPrompt(fileURL: URL) -> String? {
guard let steps = AntigravityLiveSQLite.firstSteps(
at: fileURL,
limit: Self.titleStepScanLimit
) else { return nil }
for step in steps {
if Self.stepTypeRoleMap[step.type] != nil { continue }
guard let payload = step.payload else { continue }
for run in AntigravityStepText.runs(in: payload) where !run.hasPrefix("{") {
return run
if let instruction = HumanPromptText.instruction(run) { return instruction }
}
}
return nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ public struct ClaudeCoworkSessionAdapter: SessionProviderAdapter {
// MARK: - Metadata

public func extractMetadata(fileURL: URL) throws -> SessionSummary {
try ClaudeSessionAdapter.summary(
let summary = try ClaudeSessionAdapter.summary(
fileURL: fileURL,
provider: .claudeCowork,
harness: .claudeCowork
)
guard let project = ClaudeCoworkPaths.inferredProjectDirectory(fileURL: fileURL) else {
return summary
}
return summary.withTitle(summary.title, projectDir: project)
}

// MARK: - Transcript
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ public struct ClaudeSessionAdapter: SessionProviderAdapter {
case "user": return .user
case "assistant": return .assistant
case "tool": return .tool
case "system": return .system
case "system", "developer": return .system
default: return .other
}
}
Expand Down
43 changes: 38 additions & 5 deletions Sources/AgentSessionKit/Sessions/CodexSessionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import Foundation
public struct CodexSessionAdapter: SessionProviderAdapter {
public let provider: SessionProvider = .codex

/// Encoded in `providerVariant` so hosts can fold a guardian rollout into
/// the original session without a second schema axis. The suffix is the
/// root session id from `session_meta.session_id`, which local rollouts
/// preserve even when `parent_thread_id` names an intermediate subagent.
public static let autoReviewVariantPrefix = "auto-review:"

private let hydrator: CodexTitleHydrator

public init(homeDirectory: String = RealHomeDirectory.path) {
Expand Down Expand Up @@ -71,18 +77,22 @@ public struct CodexSessionAdapter: SessionProviderAdapter {

let prompt = firstUserText(head)
let hydrated = hydrator.thread(for: sessionID)
let hydratedTitle = hydrated?.title.flatMap(HumanPromptText.instruction)
let model = Self.model(in: head) ?? Self.model(in: tail)
let autoReviewParent = Self.autoReviewParentSessionID(meta: meta, model: model)

return SessionSummary(
provider: .codex,
sessionID: sessionID,
providerVariant: autoReviewParent.map { Self.autoReviewVariantPrefix + $0 },
// Every Codex surface writes into the same tree; only the
// `originator` separates ChatGPT Work from ordinary Codex, and
// the cost scanner already owns that mapping.
harness: CodexOriginator.harness(
originator: SessionParsing.string(meta?["originator"])
),
model: Self.model(in: head) ?? Self.model(in: tail),
title: SessionParsing.display(hydrated?.title ?? prompt, limit: SessionParsing.titleLimit),
model: model,
title: SessionParsing.display(hydratedTitle ?? prompt, limit: SessionParsing.titleLimit),
summary: SessionParsing.display(prompt, limit: SessionParsing.summaryLimit),
projectDir: SessionParsing.firstString(meta?["cwd"], hydrated?.cwd),
createdAt: createdAt,
Expand All @@ -93,6 +103,28 @@ public struct CodexSessionAdapter: SessionProviderAdapter {
)
}

/// Root session id for a guardian / Auto Review rollout, or nil for an
/// ordinary Codex session.
public static func autoReviewParentSessionID(providerVariant: String?) -> String? {
guard let providerVariant,
providerVariant.hasPrefix(autoReviewVariantPrefix)
else { return nil }
return SessionParsing.string(String(providerVariant.dropFirst(autoReviewVariantPrefix.count)))
}

private static func autoReviewParentSessionID(
meta: [String: Any]?,
model: String?
) -> String? {
let source = meta?["source"] as? [String: Any]
let subagent = source?["subagent"] as? [String: Any]
let isGuardian = SessionParsing.string(subagent?["other"])?.lowercased() == "guardian"
let isReviewRuntime = SessionParsing.string(meta?["thread_source"])?.lowercased() == "subagent"
&& model?.lowercased() == "codex-auto-review"
guard isGuardian || isReviewRuntime else { return nil }
return SessionParsing.firstString(meta?["session_id"], meta?["parent_thread_id"])
}

/// The model a rollout ran on, from whichever of the three places this
/// vintage of Codex wrote it.
///
Expand Down Expand Up @@ -122,7 +154,7 @@ public struct CodexSessionAdapter: SessionProviderAdapter {
payload["role"] as? String == "user"
else { continue }
let text = Self.strippingIDEEnvelope(SessionParsing.extractText(payload["content"]))
if !text.isEmpty { return text }
if let instruction = HumanPromptText.instruction(text) { return instruction }
}
return nil
}
Expand Down Expand Up @@ -178,9 +210,10 @@ public struct CodexSessionAdapter: SessionProviderAdapter {
role = ClaudeSessionAdapter.role(payload["role"] as? String)
text = SessionParsing.extractText(payload["content"])
case "function_call":
role = .assistant
role = .tool
let name = (payload["name"] as? String) ?? "tool"
text = "[Tool: \(name)]"
let arguments = SessionParsing.string(payload["arguments"])
text = arguments.map { "[Tool: \(name)]\n\($0)" } ?? "[Tool: \(name)]"
case "function_call_output":
role = .tool
text = SessionParsing.extractText(payload["output"])
Expand Down
Loading
Loading