Skip to content
Open
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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hermes-orchestrator",
"version": "0.3.0",
"version": "0.4.0",
"description": "Supervisor framework for AI agents - classify, spawn, watch, intervene, learn. The Hermes pattern (Claude CLI subprocess) generalised.",
"type": "module",
"main": "./dist/index.js",
Expand All @@ -18,6 +18,10 @@
"types": "./dist/adapters/bonfire-memory.d.ts",
"import": "./dist/adapters/bonfire-memory.js"
},
"./adapters/file-memory": {
"types": "./dist/adapters/file-memory.d.ts",
"import": "./dist/adapters/file-memory.js"
},
"./patterns/hermes-bug-fix": {
"types": "./dist/patterns/hermes-bug-fix.d.ts",
"import": "./dist/patterns/hermes-bug-fix.js"
Expand Down
106 changes: 106 additions & 0 deletions src/adapters/file-memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { promises as fs } from 'node:fs'
import { dirname } from 'node:path'
import { homedir } from 'node:os'
import type { MemoryAdapter, OrchestratorEvent, MemoryHit } from '../types.js'

export interface FileMemoryOptions {
/**
* Path to the JSONL file storing all OrchestratorEvents.
* Default: `$HOME/.hermes-orchestrator/memory.jsonl`.
*/
path?: string
}

/**
* Local file-backed MemoryAdapter. One JSONL line per OrchestratorEvent.
*
* Retrieval joins `classified` + `completed` events by taskId, filters to the
* requested pattern, and returns recency-sorted MemoryHits. No embeddings, no
* network - just append + read. Perfect for self-hosted setups and for testing
* the learning loop end-to-end without depending on Bonfire admin labeling.
*
* The MemoryAdapter interface is the contract; FileMemory is the obvious
* default. BonfireMemory remains available for the knowledge-graph case.
*/
export class FileMemory implements MemoryAdapter {
constructor(private readonly opts: FileMemoryOptions = {}) {}

private get path(): string {
return this.opts.path ?? `${homedir()}/.hermes-orchestrator/memory.jsonl`
}

async record(event: OrchestratorEvent): Promise<void> {
await fs.mkdir(dirname(this.path), { recursive: true })
await fs.appendFile(this.path, JSON.stringify(event) + '\n', 'utf8')
}

async retrieve(pattern: string, _taskClass: string, limit: number): Promise<MemoryHit[]> {
let raw: string
try {
raw = await fs.readFile(this.path, 'utf8')
} catch (err: unknown) {
// File does not exist yet (first run) - that's not an error, just no hits.
if (isMissingFileError(err)) return []
throw err
}

const events: OrchestratorEvent[] = []
for (const line of raw.split('\n')) {
if (!line.trim()) continue
try {
events.push(JSON.parse(line) as OrchestratorEvent)
} catch {
// Skip malformed lines rather than abort the whole retrieve.
}
}

const byTask = new Map<string, OrchestratorEvent[]>()
for (const e of events) {
const list = byTask.get(e.taskId)
if (list) list.push(e)
else byTask.set(e.taskId, [e])
}

interface TaskOutcome {
taskId: string
taskText: string
summary: string
costUsd: number
completedAt: string
interventions: number
}

const completed: TaskOutcome[] = []
for (const [taskId, evts] of byTask) {
const classified = evts.find((e) => e.kind === 'classified')
const finished = evts.find((e) => e.kind === 'completed')
if (!classified || !finished) continue
if (classified.payload.pattern !== pattern) continue
completed.push({
taskId,
taskText: String(classified.payload.taskText ?? ''),
summary: String(finished.payload.summary ?? ''),
costUsd: Number(finished.payload.costUsd ?? 0),
completedAt: finished.occurredAt,
interventions: Number(finished.payload.interventions ?? 0),
})
}

// Recency-first.
completed.sort((a, b) => b.completedAt.localeCompare(a.completedAt))

return completed.slice(0, limit).map((t) => ({
name: `${pattern}:${t.taskId}`,
body: `Past task: "${t.taskText}"\nOutcome: ${t.summary}\nCost: $${t.costUsd.toFixed(3)} Interventions: ${t.interventions}`,
sourceTag: `hermes:${pattern}:completed`,
referenceTime: t.completedAt,
score: 1,
}))
}
}

function isMissingFileError(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false
const code = (err as { code?: unknown }).code
return code === 'ENOENT'
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { watch, type SupervisorOptions } from './supervisor.js'
// Re-exported here for convenience.
export { HermesRunner, type HermesRunnerOptions } from './adapters/hermes-runner.js'
export { BonfireMemory, type BonfireMemoryOptions } from './adapters/bonfire-memory.js'
export { FileMemory, type FileMemoryOptions } from './adapters/file-memory.js'
export { hermesBugFix } from './patterns/hermes-bug-fix.js'

export type {
Expand Down
186 changes: 186 additions & 0 deletions tests/file-memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mkdtemp, rm, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { FileMemory } from '../src/adapters/file-memory.js'
import type { OrchestratorEvent } from '../src/types.js'

async function makeTmpFile(): Promise<{ path: string; cleanup: () => Promise<void> }> {
const dir = await mkdtemp(join(tmpdir(), 'hermes-mem-'))
const path = join(dir, 'memory.jsonl')
return {
path,
cleanup: () => rm(dir, { recursive: true, force: true }),
}
}

function event(over: Partial<OrchestratorEvent>): OrchestratorEvent {
return {
taskId: 't-1',
kind: 'classified',
payload: {},
occurredAt: new Date().toISOString(),
...over,
}
}

describe('FileMemory', () => {
let path: string
let cleanup: () => Promise<void>

beforeEach(async () => {
const tmp = await makeTmpFile()
path = tmp.path
cleanup = tmp.cleanup
})

it('returns [] when the file does not exist', async () => {
const mem = new FileMemory({ path: `${path}.nonexistent` })
const hits = await mem.retrieve('hermes-bug-fix', 'hermes-bug-fix', 5)
expect(hits).toEqual([])
await cleanup()
})

it('records events as JSONL lines', async () => {
const mem = new FileMemory({ path })
await mem.record(event({ taskId: 't-a', kind: 'classified' }))
await mem.record(event({ taskId: 't-a', kind: 'completed' }))

const lines = (await readFile(path, 'utf8')).split('\n').filter(Boolean)
expect(lines.length).toBe(2)
expect(JSON.parse(lines[0]).kind).toBe('classified')
expect(JSON.parse(lines[1]).kind).toBe('completed')
await cleanup()
})

it('retrieves recency-sorted hits for completed tasks of the given pattern', async () => {
const mem = new FileMemory({ path })
// Task 1 - hermes-bug-fix, completed
await mem.record(
event({
taskId: 't-1',
kind: 'classified',
occurredAt: '2026-05-01T10:00:00.000Z',
payload: { pattern: 'hermes-bug-fix', taskText: 'fix the type error in foo.ts' },
}),
)
await mem.record(
event({
taskId: 't-1',
kind: 'completed',
occurredAt: '2026-05-01T10:05:00.000Z',
payload: {
pattern: 'hermes-bug-fix',
summary: 'changed foo.ts:42 from string to number',
costUsd: 0.12,
interventions: 0,
},
}),
)
// Task 2 - hermes-bug-fix, completed (newer)
await mem.record(
event({
taskId: 't-2',
kind: 'classified',
occurredAt: '2026-05-02T10:00:00.000Z',
payload: { pattern: 'hermes-bug-fix', taskText: 'fix the broken test in bar.test.ts' },
}),
)
await mem.record(
event({
taskId: 't-2',
kind: 'completed',
occurredAt: '2026-05-02T10:08:00.000Z',
payload: {
pattern: 'hermes-bug-fix',
summary: 'added missing await on async call',
costUsd: 0.18,
interventions: 1,
},
}),
)
// Task 3 - different pattern, completed
await mem.record(
event({
taskId: 't-3',
kind: 'classified',
payload: { pattern: 'research-doc', taskText: 'research vector dbs' },
}),
)
await mem.record(
event({ taskId: 't-3', kind: 'completed', payload: { pattern: 'research-doc', summary: 'wrote doc 200' } }),
)
// Task 4 - hermes-bug-fix, never completed (still running or aborted)
await mem.record(
event({
taskId: 't-4',
kind: 'classified',
payload: { pattern: 'hermes-bug-fix', taskText: 'fix the segfault' },
}),
)

const hits = await mem.retrieve('hermes-bug-fix', 'hermes-bug-fix', 5)
expect(hits.length).toBe(2)
// Newest first
expect(hits[0].name).toBe('hermes-bug-fix:t-2')
expect(hits[0].body).toContain('fix the broken test')
expect(hits[0].body).toContain('added missing await')
expect(hits[1].name).toBe('hermes-bug-fix:t-1')
expect(hits[1].body).toContain('fix the type error')
// Wrong-pattern task excluded
expect(hits.find((h) => h.name.startsWith('research-doc'))).toBeUndefined()
// Incomplete task excluded
expect(hits.find((h) => h.name === 'hermes-bug-fix:t-4')).toBeUndefined()
await cleanup()
})

it('respects the limit parameter', async () => {
const mem = new FileMemory({ path })
for (let i = 0; i < 5; i++) {
await mem.record(
event({
taskId: `t-${i}`,
kind: 'classified',
occurredAt: `2026-05-0${i + 1}T10:00:00.000Z`,
payload: { pattern: 'hermes-bug-fix', taskText: `task ${i}` },
}),
)
await mem.record(
event({
taskId: `t-${i}`,
kind: 'completed',
occurredAt: `2026-05-0${i + 1}T10:05:00.000Z`,
payload: { pattern: 'hermes-bug-fix', summary: `outcome ${i}` },
}),
)
}
const hits = await mem.retrieve('hermes-bug-fix', 'hermes-bug-fix', 2)
expect(hits.length).toBe(2)
await cleanup()
})

it('skips malformed JSONL lines rather than throwing', async () => {
const mem = new FileMemory({ path })
await mem.record(
event({
taskId: 't-good',
kind: 'classified',
payload: { pattern: 'hermes-bug-fix', taskText: 'good' },
}),
)
// Append a malformed line directly
const { appendFile } = await import('node:fs/promises')
await appendFile(path, 'not valid json\n', 'utf8')
await mem.record(
event({
taskId: 't-good',
kind: 'completed',
payload: { pattern: 'hermes-bug-fix', summary: 'fine' },
}),
)
const hits = await mem.retrieve('hermes-bug-fix', 'hermes-bug-fix', 5)
expect(hits.length).toBe(1)
expect(hits[0].body).toContain('fine')
await cleanup()
})
})
Loading