feat(uwf-hermes): Phase 2 merkle detail from Hermes session JSON
Parse session_id from Hermes stdout, store hermes-turn leaves and hermes-detail root in CAS with cas_ref turns; fall back to raw stdout when the session file is missing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
|
||||
|
||||
import {
|
||||
computeDurationMs,
|
||||
extractLastAssistantContent,
|
||||
messageToTurnPayload,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesSessionDetail,
|
||||
} from "../src/session-detail.js";
|
||||
import type { HermesSessionJson, HermesSessionMessage } from "../src/types.js";
|
||||
|
||||
describe("parseSessionIdFromStdout", () => {
|
||||
test("reads session_id from the last non-empty line", () => {
|
||||
const stdout = "Done.\n\nsession_id: 20260518_223724_45ab80\n";
|
||||
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
|
||||
});
|
||||
|
||||
test("returns null when trailing line is not session_id", () => {
|
||||
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageToTurnPayload", () => {
|
||||
test("maps assistant tool_calls to toolCalls", () => {
|
||||
const msg: HermesSessionMessage = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: null,
|
||||
tool_calls: [{ function: { name: "read_file", arguments: '{"path":"x"}' } }],
|
||||
};
|
||||
const turn = messageToTurnPayload(msg, 0);
|
||||
expect(turn).toEqual({
|
||||
index: 0,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
toolCalls: [{ name: "read_file", args: '{"path":"x"}' }],
|
||||
reasoning: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("skips user messages", () => {
|
||||
const msg: HermesSessionMessage = {
|
||||
role: "user",
|
||||
content: "hi",
|
||||
reasoning: null,
|
||||
tool_calls: null,
|
||||
};
|
||||
expect(messageToTurnPayload(msg, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLastAssistantContent", () => {
|
||||
test("returns the last non-empty assistant content", () => {
|
||||
const messages: HermesSessionMessage[] = [
|
||||
{ role: "assistant", content: "first", reasoning: null, tool_calls: null },
|
||||
{ role: "tool", content: "tool output", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "final answer", reasoning: null, tool_calls: null },
|
||||
];
|
||||
expect(extractLastAssistantContent(messages)).toBe("final answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDurationMs", () => {
|
||||
test("computes elapsed time from session_start", () => {
|
||||
const now = Date.parse("2026-05-18T13:32:59.028640Z");
|
||||
const duration = computeDurationMs("2026-05-18T13:31:59.028640Z", now);
|
||||
expect(duration).toBe(60_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeHermesSessionDetail", () => {
|
||||
test("stores hermes-detail root with cas_ref turns walkable", async () => {
|
||||
const session: HermesSessionJson = {
|
||||
session_id: "20260518_133159_6a84e8",
|
||||
model: "claude-opus-4.6",
|
||||
session_start: "2026-05-18T13:31:59.028640",
|
||||
messages: [
|
||||
{ role: "user", content: "task", reasoning: null, tool_calls: null },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "thinking",
|
||||
tool_calls: [{ function: { name: "terminal", arguments: "{}" } }],
|
||||
},
|
||||
{ role: "tool", content: "ok", reasoning: null, tool_calls: null },
|
||||
{ role: "assistant", content: "done", reasoning: null, tool_calls: null },
|
||||
],
|
||||
};
|
||||
|
||||
const store = createMemoryStore();
|
||||
const now = Date.parse("2026-05-18T13:32:59.028640");
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session, now);
|
||||
|
||||
expect(output).toBe("done");
|
||||
|
||||
const detailNode = store.get(detailHash);
|
||||
expect(detailNode).not.toBeNull();
|
||||
if (detailNode === null) {
|
||||
return;
|
||||
}
|
||||
expect(validate(store, detailNode)).toBe(true);
|
||||
expect(detailNode.payload).toMatchObject({
|
||||
sessionId: "20260518_133159_6a84e8",
|
||||
model: "claude-opus-4.6",
|
||||
duration: 60_000,
|
||||
turnCount: 3,
|
||||
});
|
||||
|
||||
const turnRefs = refs(store, detailNode);
|
||||
expect(turnRefs).toHaveLength(3);
|
||||
|
||||
const visited: string[] = [];
|
||||
walk(store, detailHash, (hash) => visited.push(hash));
|
||||
expect(visited).toContain(detailHash);
|
||||
for (const turnHash of turnRefs) {
|
||||
expect(visited).toContain(turnHash);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { bootstrap, type JSONSchema, putSchema } from "@uncaged/json-cas";
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentRunResult,
|
||||
@@ -9,19 +8,16 @@ import {
|
||||
resolveStorageRoot,
|
||||
} from "@uncaged/uwf-agent-kit";
|
||||
|
||||
import {
|
||||
loadHermesSession,
|
||||
parseSessionIdFromStdout,
|
||||
storeHermesRawOutput,
|
||||
storeHermesSessionDetail,
|
||||
} from "./session-detail.js";
|
||||
|
||||
const HERMES_COMMAND = "hermes";
|
||||
const HERMES_MAX_TURNS = 90;
|
||||
|
||||
const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = {
|
||||
title: "hermes-raw-output",
|
||||
type: "object",
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
function buildHistorySummary(history: AgentContext["history"]): string {
|
||||
if (history.length === 0) {
|
||||
return "";
|
||||
@@ -93,18 +89,22 @@ function spawnHermesChat(prompt: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function storeHermesRawOutput(rawOutput: string): Promise<string> {
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const { store } = await createAgentStore(storageRoot);
|
||||
await bootstrap(store);
|
||||
const schemaHash = await putSchema(store, HERMES_RAW_OUTPUT_SCHEMA);
|
||||
return store.put(schemaHash, { text: rawOutput });
|
||||
}
|
||||
|
||||
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
|
||||
const fullPrompt = buildHermesPrompt(ctx);
|
||||
const rawOutput = await spawnHermesChat(fullPrompt);
|
||||
const detailHash = await storeHermesRawOutput(rawOutput);
|
||||
const storageRoot = resolveStorageRoot();
|
||||
const { store } = await createAgentStore(storageRoot);
|
||||
|
||||
const sessionId = parseSessionIdFromStdout(rawOutput);
|
||||
if (sessionId !== null) {
|
||||
const session = await loadHermesSession(sessionId);
|
||||
if (session !== null) {
|
||||
const { detailHash, output } = await storeHermesSessionDetail(store, session);
|
||||
return { output, detailHash };
|
||||
}
|
||||
}
|
||||
|
||||
const detailHash = await storeHermesRawOutput(store, rawOutput);
|
||||
return { output: rawOutput, detailHash };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { JSONSchema } from "@uncaged/json-cas";
|
||||
|
||||
const HERMES_TOOL_CALL_SCHEMA: JSONSchema = {
|
||||
type: "object",
|
||||
required: ["name", "args"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
args: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const HERMES_TURN_SCHEMA: JSONSchema = {
|
||||
title: "hermes-turn",
|
||||
type: "object",
|
||||
required: ["index", "role", "content"],
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
role: { type: "string", enum: ["assistant", "tool"] },
|
||||
content: { type: "string" },
|
||||
toolCalls: {
|
||||
anyOf: [{ type: "array", items: HERMES_TOOL_CALL_SCHEMA }, { type: "null" }],
|
||||
},
|
||||
reasoning: {
|
||||
anyOf: [{ type: "string" }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export const HERMES_DETAIL_SCHEMA: JSONSchema = {
|
||||
title: "hermes-detail",
|
||||
type: "object",
|
||||
required: ["sessionId", "model", "duration", "turnCount", "turns"],
|
||||
properties: {
|
||||
sessionId: { type: "string" },
|
||||
model: { type: "string" },
|
||||
duration: { type: "integer" },
|
||||
turnCount: { type: "integer" },
|
||||
turns: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "cas_ref" },
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
/** Fallback detail when Hermes session file is unavailable. */
|
||||
export const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = {
|
||||
title: "hermes-raw-output",
|
||||
type: "object",
|
||||
required: ["text"],
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
|
||||
|
||||
import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js";
|
||||
import type {
|
||||
HermesDetailPayload,
|
||||
HermesSessionJson,
|
||||
HermesSessionMessage,
|
||||
HermesToolCall,
|
||||
HermesTurnPayload,
|
||||
HermesTurnRole,
|
||||
} from "./types.js";
|
||||
|
||||
const SESSION_ID_LINE = /^session_id:\s*(\S+)\s*$/i;
|
||||
|
||||
export function getHermesSessionsDir(): string {
|
||||
return join(homedir(), ".hermes", "sessions");
|
||||
}
|
||||
|
||||
export function getHermesSessionPath(sessionId: string): string {
|
||||
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
|
||||
}
|
||||
|
||||
/** Parse `session_id: …` from the last non-empty line of Hermes stdout. */
|
||||
export function parseSessionIdFromStdout(stdout: string): string | null {
|
||||
const lines = stdout.split(/\r?\n/).map((line) => line.trim());
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (line === undefined || line === "") {
|
||||
continue;
|
||||
}
|
||||
const match = SESSION_ID_LINE.exec(line);
|
||||
if (match?.[1] !== undefined) {
|
||||
return match[1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseToolCalls(raw: unknown): HermesSessionMessage["tool_calls"] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const calls: NonNullable<HermesSessionMessage["tool_calls"]> = [];
|
||||
for (const entry of raw) {
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
const fn = entry.function;
|
||||
if (!isRecord(fn)) {
|
||||
continue;
|
||||
}
|
||||
const name = fn.name;
|
||||
const args = fn.arguments;
|
||||
if (typeof name !== "string" || typeof args !== "string") {
|
||||
continue;
|
||||
}
|
||||
calls.push({ function: { name, arguments: args } });
|
||||
}
|
||||
return calls.length > 0 ? calls : null;
|
||||
}
|
||||
|
||||
function normalizeMessage(raw: unknown): HermesSessionMessage | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const role = raw.role;
|
||||
if (role !== "assistant" && role !== "tool" && role !== "user") {
|
||||
return null;
|
||||
}
|
||||
const content = typeof raw.content === "string" ? raw.content : raw.content === null ? null : "";
|
||||
const reasoning =
|
||||
typeof raw.reasoning === "string"
|
||||
? raw.reasoning
|
||||
: raw.reasoning === null || raw.reasoning === undefined
|
||||
? null
|
||||
: null;
|
||||
const tool_calls = parseToolCalls(raw.tool_calls);
|
||||
return { role, content, reasoning, tool_calls };
|
||||
}
|
||||
|
||||
function parseSessionJson(raw: unknown): HermesSessionJson | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
const session_id = raw.session_id;
|
||||
const model = raw.model;
|
||||
const session_start = raw.session_start;
|
||||
const messagesRaw = raw.messages;
|
||||
if (
|
||||
typeof session_id !== "string" ||
|
||||
typeof model !== "string" ||
|
||||
typeof session_start !== "string" ||
|
||||
!Array.isArray(messagesRaw)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const messages: HermesSessionMessage[] = [];
|
||||
for (const entry of messagesRaw) {
|
||||
const msg = normalizeMessage(entry);
|
||||
if (msg !== null) {
|
||||
messages.push(msg);
|
||||
}
|
||||
}
|
||||
return { session_id, model, session_start, messages };
|
||||
}
|
||||
|
||||
export async function loadHermesSession(sessionId: string): Promise<HermesSessionJson | null> {
|
||||
const path = getHermesSessionPath(sessionId);
|
||||
try {
|
||||
const text = await readFile(path, "utf8");
|
||||
const raw = JSON.parse(text) as unknown;
|
||||
return parseSessionJson(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeDurationMs(sessionStart: string, nowMs: number = Date.now()): number {
|
||||
const startMs = Date.parse(sessionStart);
|
||||
if (Number.isNaN(startMs)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, nowMs - startMs);
|
||||
}
|
||||
|
||||
function mapSessionToolCalls(
|
||||
toolCalls: HermesSessionMessage["tool_calls"],
|
||||
): HermesToolCall[] | null {
|
||||
if (toolCalls === null || toolCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return toolCalls.map((call) => ({
|
||||
name: call.function.name,
|
||||
args: call.function.arguments,
|
||||
}));
|
||||
}
|
||||
|
||||
export function messageToTurnPayload(
|
||||
message: HermesSessionMessage,
|
||||
index: number,
|
||||
): HermesTurnPayload | null {
|
||||
if (message.role !== "assistant" && message.role !== "tool") {
|
||||
return null;
|
||||
}
|
||||
const role = message.role as HermesTurnRole;
|
||||
return {
|
||||
index,
|
||||
role,
|
||||
content: message.content ?? "",
|
||||
toolCalls: mapSessionToolCalls(message.tool_calls),
|
||||
reasoning: message.reasoning,
|
||||
};
|
||||
}
|
||||
|
||||
/** Last assistant message with non-empty text content (walks backward). */
|
||||
export function extractLastAssistantContent(messages: HermesSessionMessage[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "assistant" && msg.content !== null && msg.content.trim() !== "") {
|
||||
return msg.content;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
type HermesSchemaHashes = {
|
||||
turn: string;
|
||||
detail: string;
|
||||
rawOutput: string;
|
||||
};
|
||||
|
||||
async function registerHermesSchemas(store: Store): Promise<HermesSchemaHashes> {
|
||||
await bootstrap(store);
|
||||
const [turn, detail, rawOutput] = await Promise.all([
|
||||
putSchema(store, HERMES_TURN_SCHEMA),
|
||||
putSchema(store, HERMES_DETAIL_SCHEMA),
|
||||
putSchema(store, HERMES_RAW_OUTPUT_SCHEMA),
|
||||
]);
|
||||
return { turn, detail, rawOutput };
|
||||
}
|
||||
|
||||
export async function storeHermesSessionDetail(
|
||||
store: Store,
|
||||
session: HermesSessionJson,
|
||||
nowMs: number = Date.now(),
|
||||
): Promise<{ detailHash: string; output: string }> {
|
||||
const schemas = await registerHermesSchemas(store);
|
||||
const turnHashes: string[] = [];
|
||||
let turnIndex = 0;
|
||||
|
||||
for (const message of session.messages) {
|
||||
const turn = messageToTurnPayload(message, turnIndex);
|
||||
if (turn === null) {
|
||||
continue;
|
||||
}
|
||||
const hash = await store.put(schemas.turn, turn);
|
||||
turnHashes.push(hash);
|
||||
turnIndex += 1;
|
||||
}
|
||||
|
||||
const detail: HermesDetailPayload = {
|
||||
sessionId: session.session_id,
|
||||
model: session.model,
|
||||
duration: computeDurationMs(session.session_start, nowMs),
|
||||
turnCount: turnHashes.length,
|
||||
turns: turnHashes,
|
||||
};
|
||||
const detailHash = await store.put(schemas.detail, detail);
|
||||
const output = extractLastAssistantContent(session.messages);
|
||||
return { detailHash, output };
|
||||
}
|
||||
|
||||
export async function storeHermesRawOutput(store: Store, rawOutput: string): Promise<string> {
|
||||
const schemas = await registerHermesSchemas(store);
|
||||
return store.put(schemas.rawOutput, { text: rawOutput });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type HermesTurnRole = "assistant" | "tool";
|
||||
|
||||
export type HermesToolCall = {
|
||||
name: string;
|
||||
args: string;
|
||||
};
|
||||
|
||||
export type HermesTurnPayload = {
|
||||
index: number;
|
||||
role: HermesTurnRole;
|
||||
content: string;
|
||||
toolCalls: HermesToolCall[] | null;
|
||||
reasoning: string | null;
|
||||
};
|
||||
|
||||
export type HermesDetailPayload = {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
duration: number;
|
||||
turnCount: number;
|
||||
turns: string[];
|
||||
};
|
||||
|
||||
export type HermesSessionToolCall = {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type HermesSessionMessage = {
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_calls: HermesSessionToolCall[] | null;
|
||||
reasoning: string | null;
|
||||
};
|
||||
|
||||
export type HermesSessionJson = {
|
||||
session_id: string;
|
||||
model: string;
|
||||
session_start: string;
|
||||
messages: HermesSessionMessage[];
|
||||
};
|
||||
Reference in New Issue
Block a user