From e1309ead14be511bf92c542123932ce73a6cf427 Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Fri, 19 Jun 2026 16:57:14 +0200 Subject: [PATCH 1/2] feat: import Hermes JSONL transcripts --- README.md | 4 +- .../plan.md | 158 +++++++++++++++ .../spec.md | 58 ++++++ .../todo.md | 164 +++++++++++++++ src/cli.ts | 2 +- src/replay/jsonl-parser.ts | 191 +++++++++++++++++- src/viewer/index.html | 2 +- test/fixtures/jsonl/hermes.jsonl | 7 + test/replay-import-key.test.ts | 75 ++++++- test/replay.test.ts | 55 ++++- 10 files changed, 699 insertions(+), 17 deletions(-) create mode 100644 docs/todos/2026-06-19-issue-297-import-hermes-jsonl/plan.md create mode 100644 docs/todos/2026-06-19-issue-297-import-hermes-jsonl/spec.md create mode 100644 docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md create mode 100644 test/fixtures/jsonl/hermes.jsonl diff --git a/README.md b/README.md index d0572b8b5..e73f60111 100644 --- a/README.md +++ b/README.md @@ -489,10 +489,10 @@ On Windows / PowerShell, the equivalent cache clear is `Remove-Item -Recurse -Fo Every session agentmemory records is replayable. Open the viewer, pick the **Replay** tab, and scrub through the timeline: prompts, tool calls, tool results, and responses render as discrete events with play/pause, speed control (0.5×–4×), and keyboard shortcuts (space to toggle, arrows to step). -Already have older Claude Code JSONL transcripts you want to bring in? +Already have older Claude Code or Hermes Agent JSONL transcripts you want to bring in? ```bash -# Import everything under the default ~/.claude/projects +# Import everything under the default Claude Code ~/.claude/projects npx @agentmemory/agentmemory import-jsonl # Or import a single file diff --git a/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/plan.md b/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/plan.md new file mode 100644 index 000000000..cca47b5c6 --- /dev/null +++ b/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/plan.md @@ -0,0 +1,158 @@ +# Hermes JSONL Import Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Import Hermes Agent JSONL transcripts through the existing `import-jsonl` replay path. + +**Architecture:** Extend the existing JSONL parser with flat Hermes `role` branches and small local helpers. Keep the import function, CLI, REST endpoint, session model, and observation model unchanged. + +**Tech Stack:** TypeScript ESM, Vitest, existing `src/replay/jsonl-parser.ts` and `src/functions/replay.ts` replay import harness. + +--- + +## Sprint Contract + +Goal: make `import-jsonl` import Hermes Agent JSONL transcripts into replay sessions using the existing replay observation model. + +Scope: modify `src/replay/jsonl-parser.ts`, add Hermes fixtures, add parser/import tests, update Claude-only import copy, and update task-state notes. + +Non-goals: no new CLI flags, endpoint changes, MCP tools, schema changes, migrations, dependencies, or Hermes live-hook/provider changes. + +Acceptance criteria: +- Hermes user rows import as `prompt_submit`. +- Hermes assistant text imports as `stop`. +- Hermes assistant `tool_calls` import as `pre_tool_use`. +- Hermes tool rows import as `post_tool_use` or `post_tool_failure` when explicit failure markers are present. +- Valid JSON argument strings parse to structured values; invalid strings are preserved. +- `session_meta` rows do not create observations. +- Existing Claude JSONL tests still pass. +- Replay import writes a Hermes session and observations. + +Intended verification: +- `corepack pnpm exec vitest run test/replay.test.ts` +- `corepack pnpm exec vitest run test/replay.test.ts test/replay-import-key.test.ts` +- `corepack pnpm run lint` +- `corepack pnpm run build` +- `corepack pnpm test` +- Required security gates before commit/PR prep as documented in the task record. + +## Files + +- Modify: `src/replay/jsonl-parser.ts` +- Modify: `src/cli.ts` +- Modify: `src/viewer/index.html` +- Modify: `README.md` +- Modify: `test/replay.test.ts` +- Modify: `test/replay-import-key.test.ts` +- Create: `test/fixtures/jsonl/hermes.jsonl` +- Update: `docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md` + +## Feature / Verification Matrix + +| Change | Verification method | Status | +| --- | --- | --- | +| Hermes parser rows | `test/replay.test.ts` Hermes tests | Done | +| Existing Claude parser behavior | Existing `test/replay.test.ts` tests | Done | +| Replay import storage | `test/replay-import-key.test.ts` Hermes import test | Done | +| User-facing import copy | README/CLI/viewer source inspection plus lint/build | Done | +| Type/build integrity | lint/build/full test | Done | + +## Task 1: Add Failing Parser And Import Tests + +**Files:** +- Create: `test/fixtures/jsonl/hermes.jsonl` +- Modify: `test/replay.test.ts` +- Modify: `test/replay-import-key.test.ts` + +- [x] **Step 1: Create a Hermes fixture** + +Add `test/fixtures/jsonl/hermes.jsonl` with session metadata, text blocks, successful and failed tool rows, an omitted tool result name, and invalid JSON arguments. + +- [x] **Step 2: Add parser assertions** + +Add a test in `test/replay.test.ts` that calls `parseJsonlText(fx("hermes.jsonl"), "hermes-fallback")` and asserts the expected hook sequence, text extraction, parsed tool input, invalid argument preservation, inferred tool result name, and failure hook. + +- [x] **Step 3: Add import-flow assertions** + +Add a test in `test/replay-import-key.test.ts` that writes a Hermes JSONL file, triggers `mem::replay::import-jsonl`, and asserts one imported session and nonzero observations. + +- [x] **Step 4: Verify RED** + +Run: + +```bash +corepack pnpm exec vitest run test/replay.test.ts test/replay-import-key.test.ts +``` + +Expected before implementation: Hermes tests fail because current parser emits zero observations for Hermes rows. + +## Task 2: Implement Hermes Parser Support + +**Files:** +- Modify: `src/replay/jsonl-parser.ts` + +- [x] **Step 1: Add Hermes entry fields** + +Extend `JsonlEntry` with optional top-level `role`, `content`, `tool_calls`, `tool_call_id`, `name`, `is_error`, `isError`, `status`, and `error` fields. + +- [x] **Step 2: Add local helper functions** + +Add helpers for record checks, text block detection, JSON argument parsing, Hermes tool-call extraction, explicit failure detection, and tool-result output normalization. + +- [x] **Step 3: Add Hermes role branches** + +Inside `parseJsonlText`, keep the current timestamp/session loop, then handle `role: "session_meta"`, `role: "user"`, `role: "assistant"`, and `role: "tool"` before falling back to Claude Code branches. + +- [x] **Step 4: Verify GREEN** + +Run: + +```bash +corepack pnpm exec vitest run test/replay.test.ts test/replay-import-key.test.ts +``` + +Expected: targeted tests pass. + +## Task 3: Cleanup, Review, And Broad Verification + +**Files:** +- Modify only task-owned files listed above. + +- [x] **Step 1: Focused simplification pass** + +Reread `src/replay/jsonl-parser.ts` and remove unnecessary helpers or duplicated branches while preserving parser behavior. + +- [x] **Step 2: Run repo-native checks** + +Run: + +```bash +corepack pnpm run lint +corepack pnpm run build +corepack pnpm test +``` + +Expected: all pass, aside from any clearly unrelated pre-existing or flaky failure recorded with evidence. + +- [x] **Step 3: Run required security gates** + +For this parser/deserializer change, run Semgrep or repo-defined equivalent. After staging intended files, run `gitleaks protect --staged --redact`. + +- [x] **Step 4: Update task state** + +Record arena synthesis, verification evidence, review notes, caveats, and final Feature / Verification Matrix status in `todo.md`. + +## Arena Synthesis + +Base: candidate 1. + +Grafts: +- From candidate 2: remember Hermes tool-call names by id so unnamed tool result rows still get `toolName`. +- From candidate 2: test an unnamed tool result row. +- From candidate 3: test assistant rows with empty content and tool calls emit no empty `stop`. +- From candidate 3: normalize text-block tool result arrays into useful text output. + +Rejected: +- Raw array/object `toolOutput` when text extraction is possible. +- Failure detection based on `error != null` or output string contents. +- New `--format` flag or public import option. diff --git a/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/spec.md b/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/spec.md new file mode 100644 index 000000000..43313d842 --- /dev/null +++ b/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/spec.md @@ -0,0 +1,58 @@ +# Issue 297 Hermes JSONL Import Spec + +## Design + +Support Hermes Agent JSONL in the existing `parseJsonlText` flow by detecting +top-level `role` rows alongside the current Claude Code `type` rows. This keeps +the public import command, REST endpoint, iii function, persisted session model, +and replay observation model unchanged. + +Hermes row mapping: +- `role: "session_meta"` is metadata only. Its timestamp contributes to + transcript `startedAt`/`endedAt`, but it creates no observation. +- `role: "user"` imports text content as `prompt_submit`. +- `role: "assistant"` imports non-empty text content as `stop`. +- `role: "assistant"` `tool_calls[]` imports one `pre_tool_use` observation per + call. +- `role: "tool"` imports as `post_tool_use` or `post_tool_failure` when the row + carries explicit failure markers. + +Content handling: +- String content is used directly. +- Array content may include strings or text-like blocks with `type: "text"`, + `type: "input_text"`, `type: "output_text"`, or no type and a string `text` + field. +- Non-text blocks such as images are ignored by text extraction. +- Tool result arrays use the same text extraction when possible; otherwise the + original content is preserved. + +Tool argument handling: +- Hermes `function.arguments` strings are parsed as JSON only when valid. +- Invalid argument strings are preserved unchanged. +- Tool-call names are read from `function.name` first, then top-level `name`, + then `"unknown"`. +- Tool result names use `entry.name` when present and otherwise fall back to the + prior assistant `tool_calls` name by `tool_call_id`. + +## Alternatives + +Recommended approach: inline Hermes branches in `src/replay/jsonl-parser.ts` +using small helpers. This matches current parser style and keeps the change +bounded. + +Rejected alternatives: +- Add `--format=hermes` or a REST/API format option. The row shape is + distinguishable and a public option would expand the contract unnecessarily. +- Convert Hermes rows to fake Claude rows. That would obscure raw Hermes fields + and add an intermediate format without reducing complexity. +- Infer tool failures from content strings. Only explicit failure markers should + mark `post_tool_failure` to avoid false positives. + +## Acceptance + +- Existing Claude JSONL fixtures continue to parse unchanged. +- Hermes user, assistant, tool-call, tool-result, text-block, invalid argument, + and failure rows parse into existing replay observations. +- A Hermes JSONL file imported through `mem::replay::import-jsonl` writes one + session with nonzero observations. +- No dependency, schema, migration, endpoint, MCP tool, or CLI flag change. diff --git a/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md b/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md new file mode 100644 index 000000000..60ae8f86d --- /dev/null +++ b/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md @@ -0,0 +1,164 @@ +# Issue 297 Hermes JSONL Import + +Task id: `2026-06-19-issue-297-import-hermes-jsonl` + +## Scope + +Implement GitHub issue #297 in branch `issue/297-import-hermes-jsonl`. + +Issue: https://github.com/wbugitlab1/agentmemory/issues/297 + +Parent batch record: +`/Users/A1538552/_projects/_tools/agentmemory/docs/todos/2026-06-19-issue-triage-batch-288-312/todo.md` + +## Preflight + +- Repository: `/Users/A1538552/.codex/worktrees/4b6c/agentmemory` +- Active branch: `issue/297-import-hermes-jsonl` +- Start ref: `499b53fc4a0f58d6f7b2daf674a7943de023d75a` +- `origin/main` at branch creation: `499b53fc4a0f58d6f7b2daf674a7943de023d75a` +- Target remote: `origin` (`https://github.com/wbugitlab1/agentmemory.git`) +- Unrelated dirty paths: none at preflight +- Repo-local instructions: `AGENTS.md` +- Lessons: no `docs/lessons/` directory present in this worktree + +## Issue Validation + +Status: valid. + +Evidence: +- GitHub issue #297 is open and requests Hermes Agent JSONL support in + `import-jsonl`. +- `src/replay/jsonl-parser.ts` currently reads Claude-style rows through + `entry.type` plus nested `entry.message.role` and `entry.message.content`. +- Hermes rows described by the issue use flat `role`, `content`, `tool_calls`, + and `tool_call_id` fields, so current parser branches ignore those rows. +- README documents Hermes as a supported integration, while the replay import + section documents only Claude Code JSONL. + +Residual uncertainty: +- The issue body is the available format contract; no checked-in Hermes JSONL + fixture exists before this task. + +## Sprint Contract + +Goal: make `import-jsonl` import Hermes Agent JSONL transcripts into replay +sessions using the existing replay observation model. + +Scope: +- Add Hermes JSONL parsing in `src/replay/jsonl-parser.ts`. +- Add targeted parser/import coverage under `test/` and fixtures. +- Update user-facing import copy that described `import-jsonl` as Claude-only. +- Preserve existing Claude Code JSONL parsing and replay import behavior. +- Keep the public import function/API/CLI shape unchanged unless repo evidence + proves a change is required. + +Non-goals: +- No new CLI flags. +- No version bump. +- No new MCP tools or REST endpoints. +- No persistence schema changes, migrations, or external services. +- No changes to Hermes live hook/provider behavior. + +Acceptance criteria: +- Hermes user rows import as `prompt_submit` observations. +- Hermes assistant text imports as `stop` observations. +- Hermes assistant `tool_calls` import as `pre_tool_use` observations. +- Hermes tool result rows import as `post_tool_use` observations and preserve + `tool_call_id` in `toolInput`. +- Valid JSON string tool-call arguments are parsed; invalid argument strings are + preserved rather than throwing. +- `session_meta` rows contribute timestamps but do not create observations. +- Existing Claude JSONL parser tests still pass. +- Import flow writes a session and observations for a Hermes JSONL fixture. + +Intended verification: +- Red test: `corepack pnpm exec vitest run test/replay.test.ts` +- Targeted green tests: + `corepack pnpm exec vitest run test/replay.test.ts test/replay-import-key.test.ts` +- Broader verification as needed: + `corepack pnpm run lint`, + `corepack pnpm run build`, + `corepack pnpm test` +- Required security gates before commit/PR prep for parser changes: + Semgrep/default repo gate when available, OSV only if dependency surfaces + change, and `gitleaks protect --staged --redact` after staging. + +Known boundaries: +- Parser/deserializer behavior changes trigger focused security review. +- Any schema, migration, auth, tenancy, dependency, endpoint, or CLI-contract + expansion requires a Human Checkpoint before proceeding. + +Stop conditions: +- Issue evidence is stale, duplicate, or already fixed. +- Hermes format requirements require a public API/schema/CLI change. +- Required verification cannot run or fails outside a clearly classified + unrelated/flaky failure. +- Remote write, PR creation, or merge fails or requires force/rewrite/admin + bypass. + +## Feature / Verification Matrix + +| Change | Verification method | Status | Evidence | +| --- | --- | --- | --- | +| Issue legitimacy | GitHub issue read plus parser inspection | Done | Issue #297 open; parser ignores flat Hermes role rows. | +| Arena candidates | Three isolated candidate artifacts plus judge | Done | Candidate 1 selected as base; judge returned ACCEPT. | +| Hermes parser support | TDD parser tests | Done | RED targeted test produced zero Hermes observations; GREEN targeted run passed 2 files / 17 tests. | +| Import-flow coverage | Replay import test with Hermes fixture | Done | `test/replay-import-key.test.ts` now asserts Hermes import writes one session and three observations; targeted run passed. | +| Existing Claude behavior | Existing replay tests | Done | Existing `test/replay.test.ts` tests passed in targeted run and full suite. | +| User-facing import copy | README/CLI/viewer source inspection plus lint/build | Done | README, CLI help, and Replay empty state now mention Claude Code or Hermes Agent JSONL; lint/build passed after this change. | +| Security scan | Semgrep + staged Gitleaks | Done | Semgrep default scan completed with 0 findings; `gitleaks protect --staged --redact` found no leaks. | +| PR readiness | GitHub push-prepare chain | Pending | Local implementation and broad verification done; security/PR-prep gates still in progress. | + +## Subagent Ledger + +| Workstream | Scope | Edits allowed | Expected output | Result | Residual risk | +| --- | --- | --- | --- | --- | --- | +| Arena candidate 1 | Proposed parser/test patch only, output path `/tmp/arena-issue-297-import-hermes-jsonl/candidate-1/` | No repo edits | Candidate artifact and rationale | Done: strongest base | Missing import-flow coverage in artifact. | +| Arena candidate 2 | Proposed parser/test patch only, output path `/tmp/arena-issue-297-import-hermes-jsonl/candidate-2/` | No repo edits | Candidate artifact and rationale | Done: useful grafts | Raw tool-output handling rejected for block arrays. | +| Arena candidate 3 | Proposed parser/test patch only, output path `/tmp/arena-issue-297-import-hermes-jsonl/candidate-3/` | No repo edits | Candidate artifact and rationale | Done: useful test ideas | Narrow failure detection rejected. | +| Arena judge | Candidate artifacts and rubric | No | Scores and base recommendation | Done: ACCEPT | Recommended candidate 1 as base with grafts from 2 and 3. | + +## Progress Notes + +- 2026-06-19: Read `AGENTS.md`, confirmed detached clean worktree at + `499b53fc4a0f58d6f7b2daf674a7943de023d75a`, created branch + `issue/297-import-hermes-jsonl`, and confirmed branch is active. +- 2026-06-19: Read issue #297 with `gh issue view`; validated as open and + locally plausible from `src/replay/jsonl-parser.ts`. +- 2026-06-19: Read README replay import docs, Hermes README references, + `src/functions/replay.ts`, `test/replay.test.ts`, and JSONL fixtures. +- 2026-06-19: Started arena candidates before implementation edits. +- 2026-06-19: Arena judge scored candidate 1 highest (28), candidate 2 second + (27), and candidate 3 third (25). Synthesis uses candidate 1 as the base, + grafts tool-name fallback and unnamed tool-result coverage from candidate 2, + and grafts empty assistant-content/tool-call plus tool-output text-block + coverage from candidate 3. +- 2026-06-19: Added Hermes parser and import-flow tests first. RED evidence: + `corepack pnpm exec vitest run test/replay.test.ts test/replay-import-key.test.ts` + failed because Hermes parser output had zero observations and import returned + zero observations. +- 2026-06-19: Implemented Hermes `role` row parsing in + `src/replay/jsonl-parser.ts`, with text-block extraction, JSON argument + parsing, invalid argument preservation, explicit failure detection, and + tool-name fallback from assistant `tool_calls`. +- 2026-06-19: GREEN targeted evidence: + `corepack pnpm exec vitest run test/replay.test.ts test/replay-import-key.test.ts` + passed 2 files / 17 tests after implementation and cleanup. +- 2026-06-19: Broad verification evidence: `corepack pnpm run lint` passed; + `corepack pnpm run build` passed with existing tsdown plugin-timing and + ineffective dynamic-import warnings; `corepack pnpm test` passed 207 files / + 2829 tests. +- 2026-06-19: Review findings fixed: removed pnpm-generated `allowBuilds` + placeholder block from `pnpm-workspace.yaml` and updated stale task-state + matrix/status notes. +- 2026-06-19: Updated README, CLI help, and Replay empty-state copy to describe + `import-jsonl` as supporting Claude Code or Hermes Agent JSONL transcripts. +- 2026-06-19: Post-copy verification: targeted replay tests passed 2 files / 17 + tests; `corepack pnpm run lint` passed; `corepack pnpm run build` passed with + existing tsdown plugin-timing and ineffective dynamic-import warnings; + `corepack pnpm test` passed 207 files / 2829 tests; `git diff --check` + passed; Semgrep default scan completed with 0 findings. +- 2026-06-19: Staged task-owned files only and ran + `gitleaks protect --staged --redact`; scanned about 28.89 KB and found no + leaks. diff --git a/src/cli.ts b/src/cli.ts index a232e92d6..3fbe37e1f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -181,7 +181,7 @@ Commands: unresponsive native engines and stale state. mcp Start standalone MCP shim — opt-in surface for MCP-only clients (Cursor, Gemini CLI, etc). REST always available at :3111. - import-jsonl [p] Import Claude Code JSONL transcripts (default: ~/.claude/projects) + import-jsonl [p] Import Claude Code or Hermes Agent JSONL transcripts (default: ~/.claude/projects) --max-files | --max-files=: override scan cap (default 200, max 1000; out-of-range is rejected; for trees >1000 files, batch by subdirectory) diff --git a/src/replay/jsonl-parser.ts b/src/replay/jsonl-parser.ts index 5060c3451..b13e552c6 100644 --- a/src/replay/jsonl-parser.ts +++ b/src/replay/jsonl-parser.ts @@ -7,6 +7,15 @@ interface JsonlEntry { sessionId?: string; timestamp?: string; cwd?: string; + role?: string; + content?: unknown; + tool_calls?: unknown; + tool_call_id?: unknown; + name?: unknown; + is_error?: unknown; + isError?: unknown; + status?: unknown; + error?: unknown; message?: { role?: string; content?: unknown; @@ -30,15 +39,37 @@ function deriveProject(cwd: string): string { return parts[parts.length - 1] || "unknown"; } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isTextBlockType(type: unknown): boolean { + return ( + type === undefined || + type === "text" || + type === "input_text" || + type === "output_text" + ); +} + function toText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const item of content) { - if (!item || typeof item !== "object") continue; - const entry = item as Record; - if (entry.type === "text" && typeof entry.text === "string") { + if (typeof item === "string") { + parts.push(item); + continue; + } + if (!isRecord(item)) continue; + const entry = item; + if (isTextBlockType(entry.type) && typeof entry.text === "string") { parts.push(entry.text); + } else if ( + isTextBlockType(entry.type) && + typeof entry.content === "string" + ) { + parts.push(entry.content); } } return parts.join("\n"); @@ -48,8 +79,8 @@ function extractToolUses(content: unknown): Array<{ id: string; name: string; in if (!Array.isArray(content)) return []; const out: Array<{ id: string; name: string; input: unknown }> = []; for (const item of content) { - if (!item || typeof item !== "object") continue; - const entry = item as Record; + if (!isRecord(item)) continue; + const entry = item; if (entry.type === "tool_use") { out.push({ id: typeof entry.id === "string" ? entry.id : "", @@ -65,8 +96,8 @@ function extractToolResults(content: unknown): Array<{ toolUseId: string; output if (!Array.isArray(content)) return []; const out: Array<{ toolUseId: string; output: unknown; isError: boolean }> = []; for (const item of content) { - if (!item || typeof item !== "object") continue; - const entry = item as Record; + if (!isRecord(item)) continue; + const entry = item; if (entry.type === "tool_result") { out.push({ toolUseId: typeof entry.tool_use_id === "string" ? entry.tool_use_id : "", @@ -78,6 +109,67 @@ function extractToolResults(content: unknown): Array<{ toolUseId: string; output return out; } +function parseToolArguments(args: unknown): unknown { + if (typeof args !== "string") return args; + const trimmed = args.trim(); + if (!trimmed) return args; + try { + return JSON.parse(trimmed); + } catch { + return args; + } +} + +function extractHermesToolCalls( + toolCalls: unknown, +): Array<{ id: string; name: string; input: unknown }> { + if (!Array.isArray(toolCalls)) return []; + const out: Array<{ id: string; name: string; input: unknown }> = []; + for (const item of toolCalls) { + if (!isRecord(item)) continue; + const fn = isRecord(item.function) ? item.function : undefined; + const rawName = fn?.name ?? item.name; + const rawArgs = fn?.arguments ?? item.arguments; + out.push({ + id: typeof item.id === "string" ? item.id : "", + name: + typeof rawName === "string" && rawName.trim().length > 0 + ? rawName + : "unknown", + input: parseToolArguments(rawArgs), + }); + } + return out; +} + +function isFailureStatus(value: unknown): boolean { + return ( + typeof value === "string" && + ["error", "failed", "failure"].includes(value.toLowerCase()) + ); +} + +function hasErrorValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== false && value !== ""; +} + +function isHermesToolFailure(entry: JsonlEntry): boolean { + if (entry.is_error === true || entry.isError === true) return true; + if (isFailureStatus(entry.status)) return true; + if (hasErrorValue(entry.error)) return true; + if (!isRecord(entry.content)) return false; + if (entry.content.is_error === true || entry.content.isError === true) { + return true; + } + if (isFailureStatus(entry.content.status)) return true; + return hasErrorValue(entry.content.error); +} + +function hermesToolOutput(content: unknown): unknown { + const text = toText(content); + return text.trim().length > 0 ? text : content; +} + export function parseJsonlText(text: string, fallbackSessionId?: string): ParsedTranscript { const lines = text.split("\n").filter((l) => l.trim().length > 0); const entries: JsonlEntry[] = []; @@ -96,14 +188,93 @@ export function parseJsonlText(text: string, fallbackSessionId?: string): Parsed let lastTs = ""; const observations: RawObservation[] = []; + const fallbackTimestamp = new Date().toISOString(); + const hermesToolNamesById = new Map(); for (const entry of entries) { - if (entry.sessionId && !sessionId) sessionId = entry.sessionId; - if (entry.cwd && !cwd) cwd = entry.cwd; - const ts = entry.timestamp || new Date().toISOString(); + if (typeof entry.sessionId === "string" && entry.sessionId && !sessionId) { + sessionId = entry.sessionId; + } + if (typeof entry.cwd === "string" && entry.cwd && !cwd) cwd = entry.cwd; + const ts = + typeof entry.timestamp === "string" && entry.timestamp.length > 0 + ? entry.timestamp + : fallbackTimestamp; if (!firstTs) firstTs = ts; lastTs = ts; + const hermesRole = typeof entry.role === "string" ? entry.role : ""; + if (hermesRole === "session_meta" || hermesRole === "system") { + continue; + } + + if (hermesRole === "user") { + const text = toText(entry.content); + if (text.trim().length > 0) { + observations.push({ + id: generateId("obs"), + sessionId: sessionId || "imported", + timestamp: ts, + hookType: "prompt_submit" as HookType, + userPrompt: text, + raw: entry, + }); + } + continue; + } + + if (hermesRole === "assistant") { + const text = toText(entry.content); + const tools = extractHermesToolCalls(entry.tool_calls); + if (text.trim().length > 0) { + observations.push({ + id: generateId("obs"), + sessionId: sessionId || "imported", + timestamp: ts, + hookType: "stop" as HookType, + assistantResponse: text, + raw: entry, + }); + } + for (const tool of tools) { + if (tool.id) hermesToolNamesById.set(tool.id, tool.name); + observations.push({ + id: generateId("obs"), + sessionId: sessionId || "imported", + timestamp: ts, + hookType: "pre_tool_use" as HookType, + toolName: tool.name, + toolInput: tool.input, + raw: { toolUseId: tool.id, entry }, + }); + } + continue; + } + + if (hermesRole === "tool") { + const toolUseId = + typeof entry.tool_call_id === "string" ? entry.tool_call_id : ""; + const toolName = + typeof entry.name === "string" + ? entry.name + : hermesToolNamesById.get(toolUseId); + observations.push({ + id: generateId("obs"), + sessionId: sessionId || "imported", + timestamp: ts, + hookType: (isHermesToolFailure(entry) + ? "post_tool_failure" + : "post_tool_use") as HookType, + toolName, + toolInput: { toolUseId }, + toolOutput: hermesToolOutput(entry.content), + raw: entry, + }); + continue; + } + + if (hermesRole) continue; + const role = entry.message?.role; const content = entry.message?.content; diff --git a/src/viewer/index.html b/src/viewer/index.html index 159db6bd4..bf26b3177 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -4491,7 +4491,7 @@

agentmemory

'' + '
' + renderReplayDetail(cursorEvent) + '
' + '' - : '
Pick a session to replay, or import Claude Code JSONL transcripts from ~/.claude/projects.
'); + : '
Pick a session to replay, or import Claude Code or Hermes Agent JSONL transcripts.
'); var sel = document.getElementById('replay-session-select'); if (sel) sel.addEventListener('change', function() { selectReplaySession(sel.value); }); diff --git a/test/fixtures/jsonl/hermes.jsonl b/test/fixtures/jsonl/hermes.jsonl new file mode 100644 index 000000000..d7b1529f6 --- /dev/null +++ b/test/fixtures/jsonl/hermes.jsonl @@ -0,0 +1,7 @@ +{"role":"session_meta","tools":[{"name":"shell"}],"model":"hermes-3","platform":"darwin","timestamp":"2026-05-01T08:00:00.000Z"} +{"role":"user","content":[{"type":"text","text":"List files"},{"type":"input_text","text":"Then run the failing command"},{"type":"image","source":"ignored"}],"timestamp":"2026-05-01T08:00:01.000Z"} +{"role":"assistant","content":"I'll inspect the directory.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"shell","arguments":"{\"command\":\"ls\",\"cwd\":\"/tmp/project\"}"}}],"timestamp":"2026-05-01T08:00:02.000Z"} +{"role":"tool","tool_call_id":"call_1","content":[{"type":"text","text":"README.md\nsrc\n"}],"timestamp":"2026-05-01T08:00:03.000Z"} +{"role":"assistant","content":"","tool_calls":[{"id":"call_2","type":"function","function":{"name":"shell","arguments":"{not-json}"}}],"timestamp":"2026-05-01T08:00:04.000Z"} +{"role":"tool","tool_call_id":"call_2","name":"shell","content":"exit 1","is_error":true,"timestamp":"2026-05-01T08:00:05.000Z"} +{"role":"assistant","content":"Found two entries and one failure.","finish_reason":"stop","timestamp":"2026-05-01T08:00:06.000Z"} diff --git a/test/replay-import-key.test.ts b/test/replay-import-key.test.ts index 9a4c64675..a9132d8de 100644 --- a/test/replay-import-key.test.ts +++ b/test/replay-import-key.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -67,7 +67,7 @@ describe("import-jsonl re-key on parsed.sessionId (#775)", () => { function writeFixture(sessionId: string, ts = "2026-04-17T10:00:00.000Z") { const dir = join(tmpRoot, "proj"); rmSync(dir, { recursive: true, force: true }); - require("node:fs").mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true }); const lines = [ JSON.stringify({ type: "user", @@ -94,6 +94,44 @@ describe("import-jsonl re-key on parsed.sessionId (#775)", () => { writeFileSync(join(dir, `${sessionId}.jsonl`), lines.join("\n") + "\n"); } + function writeHermesFixture() { + const dir = join(tmpRoot, "hermes"); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const lines = [ + JSON.stringify({ + role: "session_meta", + tools: [{ name: "shell" }], + model: "hermes-3", + platform: "darwin", + timestamp: "2026-05-01T08:00:00.000Z", + }), + JSON.stringify({ + role: "user", + content: "List files", + timestamp: "2026-05-01T08:00:01.000Z", + }), + JSON.stringify({ + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_1", + function: { name: "shell", arguments: "{\"command\":\"ls\"}" }, + }, + ], + timestamp: "2026-05-01T08:00:02.000Z", + }), + JSON.stringify({ + role: "tool", + tool_call_id: "call_1", + content: "README.md\nsrc\n", + timestamp: "2026-05-01T08:00:03.000Z", + }), + ]; + writeFileSync(join(dir, "hermes.jsonl"), lines.join("\n") + "\n"); + } + it("re-imports a session whose stored row is missing the `id` field without aborting the batch", async () => { writeFixture("sess-no-id"); const kv = mockKV(); @@ -151,4 +189,37 @@ describe("import-jsonl re-key on parsed.sessionId (#775)", () => { .filter((c) => c.scope === KV.sessions && c.key === "sess-fresh"); expect(sessionWrites.length).toBe(1); }); + + it("imports Hermes Agent JSONL sessions with observations", async () => { + writeHermesFixture(); + const kv = mockKV(); + const sdk = mockSdk(kv); + registerReplayFunctions(sdk, kv as never); + + const result = (await sdk.trigger("mem::replay::import-jsonl", { + path: tmpRoot, + })) as { + success: boolean; + imported?: number; + observations?: number; + sessionIds?: string[]; + }; + + expect(result.success).toBe(true); + expect(result.imported).toBe(1); + expect(result.observations).toBe(3); + expect(result.sessionIds).toHaveLength(1); + + const sessionId = result.sessionIds![0]; + const sessionWrites = kv + .getSetCalls() + .filter((c) => c.scope === KV.sessions && c.key === sessionId); + expect(sessionWrites).toHaveLength(1); + expect((sessionWrites[0].value as any).firstPrompt).toBe("List files"); + + const observationWrites = kv + .getSetCalls() + .filter((c) => c.scope === KV.observations(sessionId)); + expect(observationWrites).toHaveLength(3); + }); }); diff --git a/test/replay.test.ts b/test/replay.test.ts index f5dfb9a6e..ca7566521 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -97,6 +97,60 @@ describe("parseJsonlText", () => { const out = parseJsonlText(text, "fb-used"); expect(out.sessionId).toBe("fb-used"); }); + + it("parses Hermes Agent JSONL rows", () => { + const out = parseJsonlText(fx("hermes.jsonl"), "hermes-fallback"); + + expect(out.sessionId).toBe("hermes-fallback"); + expect(out.project).toBe("unknown"); + expect(out.cwd).toBe(process.cwd()); + expect(out.startedAt).toBe("2026-05-01T08:00:00.000Z"); + expect(out.endedAt).toBe("2026-05-01T08:00:06.000Z"); + expect(out.observations.map((o) => o.hookType)).toEqual([ + "prompt_submit", + "stop", + "pre_tool_use", + "post_tool_use", + "pre_tool_use", + "post_tool_failure", + "stop", + ]); + + expect(out.observations[0].userPrompt).toBe( + "List files\nThen run the failing command", + ); + expect(out.observations[1].assistantResponse).toBe( + "I'll inspect the directory.", + ); + + const parsedToolCall = out.observations[2]; + expect(parsedToolCall.toolName).toBe("shell"); + expect(parsedToolCall.toolInput).toEqual({ + command: "ls", + cwd: "/tmp/project", + }); + + const toolResult = out.observations[3]; + expect(toolResult.toolName).toBe("shell"); + expect(toolResult.toolInput).toEqual({ toolUseId: "call_1" }); + expect(toolResult.toolOutput).toBe("README.md\nsrc\n"); + + const invalidToolCall = out.observations[4]; + expect(invalidToolCall.toolName).toBe("shell"); + expect(invalidToolCall.toolInput).toBe("{not-json}"); + + const failedToolResult = out.observations[5]; + expect(failedToolResult.toolName).toBe("shell"); + expect(failedToolResult.toolInput).toEqual({ toolUseId: "call_2" }); + expect(failedToolResult.toolOutput).toBe("exit 1"); + + expect(out.observations[6].assistantResponse).toBe( + "Found two entries and one failure.", + ); + expect( + out.observations.map((o) => (o.raw as { role?: string }).role), + ).not.toContain("session_meta"); + }); }); describe("projectTimeline", () => { @@ -142,4 +196,3 @@ describe("projectTimeline", () => { expect(out.startedAt).toBe(out.endedAt); }); }); - From f4c3bf3d251f22dd57b2b458332ae3ec878d5563 Mon Sep 17 00:00:00 2001 From: Willi Budzinski Date: Fri, 19 Jun 2026 17:00:10 +0200 Subject: [PATCH 2/2] docs: record issue 297 base verification --- .../todo.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md b/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md index 60ae8f86d..d040e8052 100644 --- a/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md +++ b/docs/todos/2026-06-19-issue-297-import-hermes-jsonl/todo.md @@ -108,7 +108,7 @@ Stop conditions: | Existing Claude behavior | Existing replay tests | Done | Existing `test/replay.test.ts` tests passed in targeted run and full suite. | | User-facing import copy | README/CLI/viewer source inspection plus lint/build | Done | README, CLI help, and Replay empty state now mention Claude Code or Hermes Agent JSONL; lint/build passed after this change. | | Security scan | Semgrep + staged Gitleaks | Done | Semgrep default scan completed with 0 findings; `gitleaks protect --staged --redact` found no leaks. | -| PR readiness | GitHub push-prepare chain | Pending | Local implementation and broad verification done; security/PR-prep gates still in progress. | +| PR readiness | GitHub push-prepare chain | Done locally | Fetched `origin/main` at `ee6bb114b43a6b1feb36a059fde9b8c85a3c7479`, merged it cleanly, and reran targeted/full verification and Semgrep. | ## Subagent Ledger @@ -162,3 +162,14 @@ Stop conditions: - 2026-06-19: Staged task-owned files only and ran `gitleaks protect --staged --redact`; scanned about 28.89 KB and found no leaks. +- 2026-06-19: Fetched only `origin main`; `origin/main` advanced to + `ee6bb114b43a6b1feb36a059fde9b8c85a3c7479`. Merged that captured base commit + into `issue/297-import-hermes-jsonl` with no conflicts; merge commit + `5c7c7a1585d0d8bc11f380b48daf3ff696045e00`. +- 2026-06-19: Post-base verification evidence: + `corepack pnpm exec vitest run test/replay.test.ts test/replay-import-key.test.ts` + passed 2 files / 17 tests; `corepack pnpm run lint` passed; + `corepack pnpm run build` passed with existing tsdown plugin-timing and + ineffective dynamic-import warnings; `corepack pnpm test` passed 207 files / + 2832 tests; `semgrep scan --config p/default --error --metrics=off .` + completed with 0 findings.