From bb6ee5be691bac71ad5ae2b3a85e6405c79b0436 Mon Sep 17 00:00:00 2001 From: ga1ien Date: Mon, 17 Aug 2026 10:37:54 -0700 Subject: [PATCH] =?UTF-8?q?feat:=200.3.0=20=E2=80=94=20Watchtower=205.x=20?= =?UTF-8?q?payload,=20Grok,=20and=20current=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec stays 0.2.0. The package last shipped 2026-03-30. Capture now covers Grok, Codex, Cursor, OpenCode, and Kulti Meet; AgentLog consumes that wire instead of reconstructing metadata.raw_content. --- .github/workflows/ci.yml | 1 + AGENTS.md | 58 +++++++++++ CHANGELOG.md | 34 +++++++ README.md | 52 ++++++++-- package-lock.json | 8 +- package.json | 15 ++- spec/agentlog-spec.md | 1 + src/convert/grok.ts | 84 ++++++++++++++++ src/convert/payload.ts | 153 ++++++++++++++++++++++++++++++ src/convert/watchtower.ts | 40 +++++++- src/index.ts | 7 ++ src/sources.ts | 51 ++++++++++ test/sources-and-payload.test.mjs | 143 ++++++++++++++++++++++++++++ tsup.config.ts | 2 + 14 files changed, 630 insertions(+), 19 deletions(-) create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 src/convert/grok.ts create mode 100644 src/convert/payload.ts create mode 100644 src/sources.ts create mode 100644 test/sources-and-payload.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa2fe38..73d3610 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: with: node-version: 22 - run: npm install + - run: npm test - run: npm run typecheck - run: npm run build diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ad38190 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# AGENTS.md + +How a coding agent works on AgentLog. Humans start at [README.md](./README.md). + +**Package 0.3.0** · spec **0.2.0** · last product freeze 2026-03-30 · +upgraded 2026-08-17 against Watchtower 5.x. + +## What this repo is + +The **interchange format**. Watchtower captures. AgentLog validates and +converts. Do not add a second capture engine here. + +| Lives here | Does not live here | +|---|---| +| Spec, JSON Schema, Zod, types | Floor, board, Fly indexer | +| Converters *to* AgentLog JSON | Disk discovery of ~/.codex, ~/.cursor | +| `AGENT_SOURCES` | Ora `ActorContext` (that stays in ora-ai `packages/agentlog` 0.1.0) | + +The ora-ai workspace copy is still **0.1.0** plus ActorContext. Do not +overwrite it from this tree. Do not copy ActorContext into this Apache +repo. + +## Do this first + +1. `npm install && npm test && npm run typecheck && npm run build` +2. A new converter must emit a document `validateAgentLog` accepts. +3. New Watchtower sources go in `src/sources.ts` and the watchtower + name/provider maps. Do not invent a parallel enum. +4. Capture on disk stays in `@braintied/watchtower` / + `github.com/braintied/watchtower`. This package consumes + `SessionPayload`. + +## Convert paths + +```ts +import { exportFromPayload } from '@braintied/agentlog/convert/payload'; +import { exportWatchtowerSession } from '@braintied/agentlog/convert/watchtower'; +import { convertGrokHistory } from '@braintied/agentlog/convert/grok'; +``` + +`exportWatchtowerSession(..., { messages })` uses live +`session_messages`. Without that it falls back to +`metadata.raw_content` (`[user] ` / `[assistant] `), which is the +March 2026 reconstruction and loses tool rows. + +Session keys are `source:` (`grok:…`, `claude:…`). The converter +splits on the first colon. + +## Do not + +- Bump `specVersion` for a converter-only change. Spec 0.2.0 stays + until the schema grows. +- Publish `@braintied/agentlog` from ora-ai. This repo is the public + package. +- Point strangers at `ora-watchtower.fly.dev`. +- Add Aider/Codex disk parsers that duplicate Watchtower adapters + unless Watchtower itself grew a new on-disk format this package + must read without Watchtower installed. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..557783e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 0.3.0 — 2026-08-17 + +Five months after 0.2.1 (2026-03-30). Spec stays **0.2.0**. The package +caught up to Watchtower 5.x. + +- `AGENT_SOURCES`: `claude_code`, `grok`, `codex`, `cursor`, + `opencode`, `gemini`, `kulti_meet` +- `convert/payload`: Watchtower `SessionPayload` (the live webhook) +- `convert/watchtower`: prefers `session_messages` over + `metadata.raw_content`; maps grok / opencode / kulti_meet +- `convert/grok`: `chat_history.jsonl` +- Tests: `npm test` +- AGENTS.md for implementers + +Did not publish to npm from this change. Did not merge Ora +`ActorContext` into this Apache tree. + +## 0.2.1 — 2026-03-30 + +JSON Schema, spec, and examples aligned to 0.2.0. + +## 0.2.0 — 2026-03-30 + +Twelve event types, OTel fields, multi-agent team object. + +## 0.1.1 — 2026-03-30 + +JSON Schema and debugging example. + +## 0.1.0 — 2026-03-30 + +First public release. diff --git a/README.md b/README.md index db0da99..d0cfb1b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ AgentLog defines a portable JSON format for recording what happens during AI age ## The Problem -Every AI coding tool (Claude Code, Cursor, Codex, Aider) produces session logs in its own proprietary format. There's no way to: +Every AI coding tool (Claude Code, Grok, Codex, Cursor, OpenCode, Kulti) produces session logs in its own proprietary format. There's no way to: - **Search** across sessions from different tools - **Measure** AI coding impact across your engineering org @@ -79,12 +79,37 @@ const session = await convertClaudeCodeSession('~/.claude/projects/myproject/ses console.log(session.events.length, 'events captured'); ``` -### Export from Watchtower +### Export from Watchtower 5.x + +Watchtower is the capture product. AgentLog is the interchange format. +Hooks and disk adapters already emit a `SessionPayload`. Convert that: + +```typescript +import { exportFromPayload } from '@braintied/agentlog/convert/payload'; + +const agentLog = exportFromPayload(webhookBody); +``` + +Or a `coding_sessions` row, with live `session_messages` when you have them +(they win over the old `metadata.raw_content` reconstruction): ```typescript import { exportWatchtowerSession } from '@braintied/agentlog/convert/watchtower'; -const agentLog = exportWatchtowerSession(dbRow, { projectName: 'my-app' }); +const agentLog = exportWatchtowerSession(dbRow, { + projectName: 'my-app', + messages: sessionMessages, +}); +``` + +### Convert a Grok history file + +```typescript +import { convertGrokHistory } from '@braintied/agentlog/convert/grok'; + +const session = await convertGrokHistory( + '~/.grok/sessions/.../chat_history.jsonl', +); ``` ## Schema @@ -108,7 +133,8 @@ The required context: who, when, where, what tool. ### Layer 2 — Event Timeline -Seven event types capture everything that happened: +Twelve event types (spec 0.2.0). Capture produces `message` and +`toolCall` first; the rest are for producers that have them. | Type | What it captures | Key fields | |------|-----------------|------------| @@ -119,6 +145,11 @@ Seven event types capture everything that happened: | **`search`** | Code/web search | `tool`, `query`, `resultCount` | | **`reasoning`** | AI decision-making | `intent`, `alternatives`, `rationale` | | **`error`** | Errors + recovery | `message`, `code`, `recovery`, `resolved` | +| **`handoff`** | Agent-to-agent | `fromAgent`, `toAgent`, `reason` | +| **`approval`** | Human gates | `action`, `approver`, `decision` | +| **`plan`** | Planned steps | `title`, `steps` | +| **`checkpoint`** | Save points | `checkpointType`, `label` | +| **`contextLoad`** | Injected context | `source`, `query` | All events share: `id`, `timestamp`, `parentId` (for nesting), `durationMs`, `properties` (extensibility). @@ -160,11 +191,16 @@ Every object has a `properties` bag for vendor-specific data ([SARIF pattern](ht | Converter | Status | |-----------|--------| +| Watchtower `SessionPayload` (5.x wire) | Available (`convert/payload`) | +| Watchtower `coding_sessions` + `session_messages` | Available (`convert/watchtower`) | | Claude Code JSONL | Available | -| Watchtower DB | Available | -| Aider | Planned | -| OpenAI Codex | Planned | -| Cursor | Planned | +| Grok `chat_history.jsonl` | Available (`convert/grok`) | +| Codex / Cursor / OpenCode disk | Capture is Watchtower's job. Feed the payload. | +| Aider | Not started | + +Sources named in `AGENT_SOURCES`: `claude_code`, `grok`, `codex`, +`cursor`, `opencode`, `gemini`, `kulti_meet`. A missing source is not +silently remapped. ## Examples diff --git a/package-lock.json b/package-lock.json index 6397101..9ef1bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@agentlog/schema", - "version": "0.1.0", + "name": "@braintied/agentlog", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@agentlog/schema", - "version": "0.1.0", + "name": "@braintied/agentlog", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index 70f554a..12978b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@braintied/agentlog", - "version": "0.2.1", - "description": "AgentLog — Open standard for AI agent session interchange. Schema, types, validation, and converters.", + "version": "0.3.0", + "description": "Portable JSON for AI agent sessions. Spec 0.2.0. Converts Claude Code, Grok, Codex, Cursor, OpenCode, and Watchtower 5.x.", "homepage": "https://github.com/braintied/agentlog", "repository": { "type": "git", @@ -22,6 +22,14 @@ "types": "./dist/convert/watchtower.d.ts", "import": "./dist/convert/watchtower.js" }, + "./convert/payload": { + "types": "./dist/convert/payload.d.ts", + "import": "./dist/convert/payload.js" + }, + "./convert/grok": { + "types": "./dist/convert/grok.d.ts", + "import": "./dist/convert/grok.js" + }, "./schemas/agentlog.schema.json": "./schemas/agentlog.schema.json" }, "files": [ @@ -32,7 +40,8 @@ ], "scripts": { "build": "tsup", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test test/*.test.mjs" }, "dependencies": { "zod": "^4.3.6" diff --git a/spec/agentlog-spec.md b/spec/agentlog-spec.md index 367eb1f..944f409 100644 --- a/spec/agentlog-spec.md +++ b/spec/agentlog-spec.md @@ -3,6 +3,7 @@ **Version**: 0.2.0 **Status**: Draft **Date**: 2026-03-30 +**Updated**: 2026-08-17 (implementations: package 0.3.0; schema unchanged) **License**: Apache-2.0 ## 1. Introduction diff --git a/src/convert/grok.ts b/src/convert/grok.ts new file mode 100644 index 0000000..219c20f --- /dev/null +++ b/src/convert/grok.ts @@ -0,0 +1,84 @@ +/** + * Grok `chat_history.jsonl` → AgentLog. + * + * Path: ~/.grok/sessions///chat_history.jsonl + * Capture still belongs to Watchtower. This converter is for a file + * you already have. + * + * @license Apache-2.0 + */ + +import { readFile } from 'node:fs/promises'; +import { basename, dirname } from 'node:path'; +import type { AgentLog } from '../schema.js'; +import { exportFromPayload, type CapturedMessage, type PayloadConvertOptions } from './payload.js'; + +export interface GrokConvertOptions extends PayloadConvertOptions { + sessionId?: string; +} + +export async function convertGrokHistory( + historyPath: string, + options: GrokConvertOptions = {}, +): Promise { + const text = await readFile(historyPath, 'utf8'); + const messages: CapturedMessage[] = []; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed === '') continue; + let entry: Record; + try { + entry = JSON.parse(trimmed) as Record; + } catch { + continue; + } + const kind = typeof entry.type === 'string' ? entry.type : ''; + if (kind === 'system' || kind === 'reasoning' || kind === 'tool_result') continue; + let role = ''; + let content = ''; + if (kind === 'user') { + const raw = extractText(entry.content).trim(); + if (raw.includes('') && !raw.includes('')) continue; + if (raw.startsWith('') && !raw.includes('')) continue; + const query = raw.match(/\s*([\s\S]*?)\s*<\/user_query>/); + content = query !== null && query[1].trim() !== '' ? query[1].trim() : raw; + role = 'user'; + } else if (kind === 'assistant') { + role = 'assistant'; + content = extractText(entry.content).trim(); + } else { + continue; + } + if (content === '') continue; + const timestamp = typeof entry.timestamp === 'string' + ? entry.timestamp + : new Date(0).toISOString(); + messages.push({ role, content, timestamp }); + } + + const sessionDir = dirname(historyPath); + const sessionId = options.sessionId !== undefined ? options.sessionId : basename(sessionDir); + return exportFromPayload({ + session_key: `grok:${sessionId}`, + source: 'grok', + messages, + message_count: messages.length, + session_started_at: messages.length > 0 ? messages[0].timestamp : undefined, + session_ended_at: messages.length > 0 ? messages[messages.length - 1].timestamp : undefined, + }, options); +} + +function extractText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((part) => { + if (typeof part === 'string') return part; + if (part !== null && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string') { + return (part as { text: string }).text; + } + return ''; + }) + .filter((part) => part !== '') + .join('\n'); +} diff --git a/src/convert/payload.ts b/src/convert/payload.ts new file mode 100644 index 0000000..e14c17f --- /dev/null +++ b/src/convert/payload.ts @@ -0,0 +1,153 @@ +/** + * Watchtower SessionPayload → AgentLog + * + * This is the live interchange. Watchtower 5.x hooks and disk adapters + * already emit this shape. AgentLog does not re-implement capture. + * + * @license Apache-2.0 + */ + +import type { AgentLog, MessageEvent, SessionEvent, SessionMetrics } from '../schema.js'; +import { SPEC_VERSION } from '../schema.js'; +import { describeSource, sourceFromSessionKey } from '../sources.js'; + +const BASE_DEFAULTS = { + tokenUsage: null, + estimatedCostUsd: null, + model: null, + traceId: null, + spanId: null, + groupId: null, +} as const; + +export interface CapturedMessage { + role: string; + content: string; + timestamp: string; +} + +/** + * Wire Watchtower posts to `/webhooks/session` (5.0+). + */ +export interface SessionPayload { + session_key: string; + source: string; + project_slug?: string; + messages: CapturedMessage[]; + tools_used?: string[]; + message_count: number; + session_started_at?: string; + session_ended_at?: string; + metadata?: Record; +} + +export interface PayloadConvertOptions { + projectName?: string; + repository?: string; + workingDirectory?: string; + developerId?: string; + developerName?: string; +} + +export function exportFromPayload( + payload: SessionPayload, + options: PayloadConvertOptions = {}, +): AgentLog { + const source = payload.source !== '' ? payload.source : sourceFromSessionKey(payload.session_key); + const info = describeSource(source); + const events: SessionEvent[] = payload.messages.map((message, index) => messageEvent(message, index)); + const startTime = payload.session_started_at !== undefined + ? payload.session_started_at + : firstTimestamp(payload.messages); + const endTime = payload.session_ended_at !== undefined ? payload.session_ended_at : null; + const toolsUsed = payload.tools_used !== undefined ? payload.tools_used : []; + + const metrics: SessionMetrics = { + messageCount: payload.message_count, + toolCallCount: toolsUsed.length, + filesTouchedCount: 0, + durationMinutes: null, + tokenUsage: null, + estimatedCostUsd: null, + filesTouched: [], + toolsUsed, + modelsUsed: [], + properties: {}, + }; + + const projectName = options.projectName !== undefined + ? options.projectName + : payload.project_slug; + + return { + specVersion: SPEC_VERSION, + id: idFromSessionKey(payload.session_key), + startTime, + endTime, + status: endTime !== null ? 'completed' : 'active', + agent: { + name: info.name, + version: null, + model: null, + provider: info.provider, + properties: { source }, + }, + project: projectName !== undefined + ? { + name: projectName, + repository: options.repository !== undefined ? options.repository : null, + workingDirectory: options.workingDirectory !== undefined ? options.workingDirectory : null, + branch: null, + commitSha: null, + properties: {}, + } + : null, + developer: options.developerId !== undefined + ? { + id: options.developerId, + name: options.developerName !== undefined ? options.developerName : null, + properties: {}, + } + : null, + team: null, + events, + metrics, + relationships: null, + classification: null, + redactions: null, + profile: null, + properties: { + sessionKey: payload.session_key, + source, + converter: 'payload', + converterVersion: '0.3.0', + ...(payload.metadata !== undefined ? { payloadMetadata: payload.metadata } : {}), + }, + }; +} + +function messageEvent(message: CapturedMessage, index: number): MessageEvent { + const role = message.role === 'assistant' || message.role === 'system' ? message.role : 'user'; + return { + type: 'message', + id: `evt-${index}`, + timestamp: message.timestamp, + parentId: null, + durationMs: null, + ...BASE_DEFAULTS, + role, + content: message.content, + properties: {}, + }; +} + +function firstTimestamp(messages: CapturedMessage[]): string { + if (messages.length > 0) return messages[0].timestamp; + return new Date(0).toISOString(); +} + +function idFromSessionKey(sessionKey: string): string { + const colon = sessionKey.indexOf(':'); + if (colon >= 0 && colon < sessionKey.length - 1) return sessionKey.slice(colon + 1); + return sessionKey; +} diff --git a/src/convert/watchtower.ts b/src/convert/watchtower.ts index aabfc7a..daf3035 100644 --- a/src/convert/watchtower.ts +++ b/src/convert/watchtower.ts @@ -47,6 +47,14 @@ export interface WatchtowerSessionRow { analyzed_at: string | null; } +/** watchtower.session_messages as of Watchtower 5.x */ +export interface WatchtowerMessageRow { + role: string; + content: string; + created_at?: string; + timestamp?: string; +} + export interface WatchtowerCommitRow { sha: string; message: string | null; @@ -69,6 +77,12 @@ export interface ExportOptions { /** Developer display name. */ developerName?: string; + + /** + * Live session_messages rows. When present these win over + * metadata.raw_content (the March 2026 reconstruction). + */ + messages?: WatchtowerMessageRow[]; } // ============================================================================= @@ -85,9 +99,20 @@ export function exportWatchtowerSession( const events: SessionEvent[] = []; let eventIndex = 0; + const liveMessages = options?.messages; + if (liveMessages !== undefined && liveMessages.length > 0) { + for (const message of liveMessages) { + const ts = message.timestamp !== undefined + ? message.timestamp + : (message.created_at !== undefined ? message.created_at : row.session_started_at); + const role = message.role === 'assistant' || message.role === 'system' ? message.role : 'user'; + events.push(buildMessageEvent(role, message.content, ts !== undefined ? ts : null, eventIndex++)); + } + } + // Reconstruct events from raw_content in metadata const metadata = row.metadata; - if (metadata !== null && metadata !== undefined) { + if (events.length === 0 && metadata !== null && metadata !== undefined) { const rawContent = typeof metadata.raw_content === 'string' ? metadata.raw_content : null; if (rawContent !== null) { @@ -209,7 +234,8 @@ export function exportWatchtowerSession( watchtowerSummary: row.ai_summary, watchtowerDecisions: row.decisions, converter: 'watchtower', - converterVersion: SPEC_VERSION, + converterVersion: '0.3.0', + source: row.source, }, }; @@ -221,7 +247,7 @@ export function exportWatchtowerSession( // ============================================================================= function buildMessageEvent( - role: 'user' | 'assistant', + role: 'user' | 'assistant' | 'system', content: string, sessionStartTime: string | null, index: number, @@ -244,7 +270,10 @@ function mapSourceToAgentName(source: string): string { case 'claude_code': return 'Claude Code'; case 'cursor': return 'Cursor'; case 'codex': return 'OpenAI Codex'; - case 'gemini': return 'Gemini'; + case 'gemini': return 'Gemini CLI'; + case 'grok': return 'Grok'; + case 'opencode': return 'OpenCode'; + case 'kulti_meet': return 'Kulti Meet'; default: return source; } } @@ -255,6 +284,9 @@ function mapSourceToProvider(source: string): string { case 'cursor': return 'cursor'; case 'codex': return 'openai'; case 'gemini': return 'google'; + case 'grok': return 'xai'; + case 'opencode': return 'opencode'; + case 'kulti_meet': return 'braintied'; default: return 'unknown'; } } diff --git a/src/index.ts b/src/index.ts index d28f95c..8a3b1f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,13 @@ export type { // Constants export { SPEC_VERSION } from './schema.js'; +export { + AGENT_SOURCES, + describeSource, + isAgentSource, + sourceFromSessionKey, +} from './sources.js'; +export type { AgentSource, AgentSourceInfo } from './sources.js'; // Validation — individual schemas exported for Zod v4 compatibility export { diff --git a/src/sources.ts b/src/sources.ts new file mode 100644 index 0000000..1977249 --- /dev/null +++ b/src/sources.ts @@ -0,0 +1,51 @@ +/** + * Source ids AgentLog understands in 0.3.0. + * + * These match Watchtower 5.x `coding_sessions.source` / session_key + * (`source:`). A missing id is not remapped to another vendor. + * + * @license Apache-2.0 + */ + +export const AGENT_SOURCES = [ + 'claude_code', + 'grok', + 'codex', + 'cursor', + 'opencode', + 'gemini', + 'kulti_meet', +] as const; + +export type AgentSource = (typeof AGENT_SOURCES)[number]; + +export interface AgentSourceInfo { + id: AgentSource; + name: string; + provider: string; +} + +const SOURCE_INFO: Record = { + claude_code: { id: 'claude_code', name: 'Claude Code', provider: 'anthropic' }, + grok: { id: 'grok', name: 'Grok', provider: 'xai' }, + codex: { id: 'codex', name: 'OpenAI Codex', provider: 'openai' }, + cursor: { id: 'cursor', name: 'Cursor', provider: 'cursor' }, + opencode: { id: 'opencode', name: 'OpenCode', provider: 'opencode' }, + gemini: { id: 'gemini', name: 'Gemini CLI', provider: 'google' }, + kulti_meet: { id: 'kulti_meet', name: 'Kulti Meet', provider: 'braintied' }, +}; + +export function isAgentSource(value: string): value is AgentSource { + return (AGENT_SOURCES as readonly string[]).includes(value); +} + +export function sourceFromSessionKey(sessionKey: string): string { + const colon = sessionKey.indexOf(':'); + if (colon <= 0) return sessionKey; + return sessionKey.slice(0, colon); +} + +export function describeSource(source: string): AgentSourceInfo { + if (isAgentSource(source)) return SOURCE_INFO[source]; + return { id: source as AgentSource, name: source, provider: 'unknown' }; +} diff --git a/test/sources-and-payload.test.mjs b/test/sources-and-payload.test.mjs new file mode 100644 index 0000000..35a2a82 --- /dev/null +++ b/test/sources-and-payload.test.mjs @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { + AGENT_SOURCES, + describeSource, + isAgentSource, + sourceFromSessionKey, + validateAgentLog, +} from '../dist/index.js'; +import { exportFromPayload } from '../dist/convert/payload.js'; +import { exportWatchtowerSession } from '../dist/convert/watchtower.js'; +import { convertGrokHistory } from '../dist/convert/grok.js'; + +test('Watchtower 5.x sources are named, not guessed', () => { + for (const id of ['claude_code', 'grok', 'codex', 'cursor', 'opencode', 'gemini', 'kulti_meet']) { + assert.equal(isAgentSource(id), true, id); + } + assert.equal(AGENT_SOURCES.includes('grok'), true); + assert.equal(describeSource('grok').name, 'Grok'); + assert.equal(describeSource('kulti_meet').provider, 'braintied'); + assert.equal(sourceFromSessionKey('grok:abc'), 'grok'); + assert.equal(isAgentSource('chatgpt'), false); +}); + +test('exportFromPayload produces a valid AgentLog from a Watchtower wire payload', () => { + const doc = exportFromPayload({ + session_key: 'grok:sess-1', + source: 'grok', + project_slug: 'stack', + message_count: 2, + tools_used: ['read_file'], + session_started_at: '2026-08-17T17:00:00Z', + session_ended_at: '2026-08-17T17:10:00Z', + messages: [ + { role: 'user', content: 'upgrade agentlog', timestamp: '2026-08-17T17:00:00Z' }, + { role: 'assistant', content: 'on it', timestamp: '2026-08-17T17:01:00Z' }, + ], + }); + const result = validateAgentLog(doc); + assert.equal(result.success, true, result.success === false ? String(result.error) : ''); + assert.equal(doc.specVersion, '0.2.0'); + assert.equal(doc.id, 'sess-1'); + assert.equal(doc.agent.name, 'Grok'); + assert.equal(doc.agent.properties.source, 'grok'); + assert.equal(doc.events.length, 2); + assert.equal(doc.properties.sessionKey, 'grok:sess-1'); +}); + +test('watchtower exporter prefers session_messages over raw_content', () => { + const doc = exportWatchtowerSession({ + id: 'row-1', + session_key: 'claude:old', + source: 'claude_code', + title: 't', + ai_summary: null, + category: null, + files_touched: [], + tools_used: ['Bash'], + decisions: null, + duration_minutes: 4, + message_count: 1, + metadata: { raw_content: '[user] stale transcript' }, + session_started_at: '2026-08-17T17:00:00Z', + session_ended_at: '2026-08-17T17:04:00Z', + analyzed_at: null, + }, { + messages: [ + { role: 'user', content: 'live row', created_at: '2026-08-17T17:00:10Z' }, + ], + }); + assert.equal(doc.events.length, 1); + assert.equal(doc.events[0].type, 'message'); + if (doc.events[0].type === 'message') { + assert.equal(doc.events[0].content, 'live row'); + assert.notEqual(doc.events[0].content, 'stale transcript'); + } + assert.equal(doc.agent.name, 'Claude Code'); + assert.equal(validateAgentLog(doc).success, true); +}); + +test('watchtower maps grok and kulti_meet', () => { + const grok = exportWatchtowerSession({ + id: 'g', + session_key: 'grok:1', + source: 'grok', + title: null, + ai_summary: null, + category: null, + files_touched: null, + tools_used: null, + decisions: null, + duration_minutes: null, + message_count: 0, + metadata: null, + session_started_at: '2026-08-17T17:00:00Z', + session_ended_at: null, + analyzed_at: null, + }); + assert.equal(grok.agent.name, 'Grok'); + assert.equal(grok.agent.provider, 'xai'); + + const kulti = exportWatchtowerSession({ + id: 'k', + session_key: 'kulti_meet:room', + source: 'kulti_meet', + title: null, + ai_summary: null, + category: null, + files_touched: null, + tools_used: null, + decisions: null, + duration_minutes: null, + message_count: 0, + metadata: null, + session_started_at: '2026-08-17T17:00:00Z', + session_ended_at: null, + analyzed_at: null, + }); + assert.equal(kulti.agent.name, 'Kulti Meet'); +}); + +test('convertGrokHistory reads user_query and skips system reminders', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentlog-grok-')); + const path = join(dir, 'chat_history.jsonl'); + writeFileSync(path, [ + JSON.stringify({ type: 'user', timestamp: '2026-08-17T17:00:00Z', content: 'x' }), + JSON.stringify({ type: 'user', timestamp: '2026-08-17T17:00:01Z', content: 'fix the hook' }), + JSON.stringify({ type: 'assistant', timestamp: '2026-08-17T17:00:02Z', content: 'fixed' }), + JSON.stringify({ type: 'reasoning', timestamp: '2026-08-17T17:00:02Z', content: 'noise' }), + ].join('\n')); + const doc = await convertGrokHistory(path, { sessionId: 'abc' }); + assert.equal(doc.agent.name, 'Grok'); + assert.equal(doc.id, 'abc'); + assert.equal(doc.events.length, 2); + if (doc.events[0].type === 'message') { + assert.equal(doc.events[0].content, 'fix the hook'); + } + assert.equal(validateAgentLog(doc).success, true); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 63d63f6..1062574 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ 'src/index.ts', 'src/convert/claude-code.ts', 'src/convert/watchtower.ts', + 'src/convert/payload.ts', + 'src/convert/grok.ts', ], format: ['esm'], dts: true,