From 1d530e938c19265a6a71bed1bbe9917adb4fe26a Mon Sep 17 00:00:00 2001 From: cyork Date: Sun, 21 Jun 2026 08:29:05 -0400 Subject: [PATCH] feat: add HooksEngine rules-based alerting and inspect CLI (#12, #13) - HooksEngine: rules with event/condition/action stored in JSON state file; conditions support minLatencyMs, onError, regex pattern; history bounded to 200 entries; currently supports 'log' action - Engine fires hooks on search (with latency), reindex, work-memory, consolidation, and feedback events - GET /inspect/stats returns aggregate counts for all memory subsystems - CRUD HTTP routes for hooks: GET/POST /hooks, GET/PATCH/DELETE /hooks/:id, GET /hooks/:id/history - MCP tools: inspect_stats, add_hook_rule, list_hook_rules - npm run inspect CLI command displays a formatted stats table - Hooks test suite with 20 assertions covering all conditions and persistence Co-Authored-By: Claude Sonnet 4.6 --- agent-memory-mesh/package.json | 1 + agent-memory-mesh/src/cli/inspect.ts | 29 +++++ agent-memory-mesh/src/config.ts | 3 + agent-memory-mesh/src/memory/hooks.ts | 149 ++++++++++++++++++++++++ agent-memory-mesh/src/service/engine.ts | 105 +++++++++++++++-- agent-memory-mesh/src/service/http.ts | 48 ++++++++ agent-memory-mesh/src/service/mcp.ts | 75 ++++++++++++ agent-memory-mesh/test/hooks.test.ts | 111 ++++++++++++++++++ agent-memory-mesh/test/run-tests.ts | 2 + 9 files changed, 511 insertions(+), 12 deletions(-) create mode 100644 agent-memory-mesh/src/cli/inspect.ts create mode 100644 agent-memory-mesh/src/memory/hooks.ts create mode 100644 agent-memory-mesh/test/hooks.test.ts diff --git a/agent-memory-mesh/package.json b/agent-memory-mesh/package.json index 30b97c5..7b15262 100644 --- a/agent-memory-mesh/package.json +++ b/agent-memory-mesh/package.json @@ -9,6 +9,7 @@ "serve:mcp": "tsx src/cli/serve.ts --mcp", "consolidate": "tsx src/cli/consolidate.ts", "graph": "tsx src/cli/graph.ts", + "inspect": "tsx src/cli/inspect.ts", "test": "tsx test/run-tests.ts", "typecheck": "tsc --noEmit" }, diff --git a/agent-memory-mesh/src/cli/inspect.ts b/agent-memory-mesh/src/cli/inspect.ts new file mode 100644 index 0000000..36d49aa --- /dev/null +++ b/agent-memory-mesh/src/cli/inspect.ts @@ -0,0 +1,29 @@ +/** inspect — display current memory state statistics. */ +import "dotenv/config"; +import { loadConfig } from "../config.js"; +import { MemoryEngine } from "../service/engine.js"; + +async function main() { + const cfg = loadConfig(); + const engine = new MemoryEngine(cfg); + const stats = engine.getStats(); + + console.log("=== Agent Memory Inspect ===\n"); + console.log(`Work memory`); + console.log(` Entries : ${stats.workMemory.total}`); + console.log(` Sessions : ${stats.workMemory.sessions}`); + console.log(""); + console.log(`Context graph`); + console.log(` Entities : ${stats.graph.entities}`); + console.log(` Edges : ${stats.graph.edges}`); + console.log(""); + console.log(`Feedback`); + console.log(` Signals : ${stats.feedback.signals}`); + console.log(` Upvotes : ${stats.feedback.upvotes}`); + console.log(` Downvotes: ${stats.feedback.downvotes}`); + console.log(""); + console.log(`Retrieval policies : ${stats.policies.total}`); + console.log(`Hook rules : ${stats.hooks.rules}`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/agent-memory-mesh/src/config.ts b/agent-memory-mesh/src/config.ts index 9c507dc..038f94f 100644 --- a/agent-memory-mesh/src/config.ts +++ b/agent-memory-mesh/src/config.ts @@ -32,6 +32,8 @@ export interface MemoryConfig { * Leave empty for rule-based synthesis (no LLM required). */ consolidationModel: string; + /** JSON file path for hooks/alert rules state. */ + hooksStatePath: string; } export function loadConfig(): MemoryConfig { @@ -50,5 +52,6 @@ export function loadConfig(): MemoryConfig { port: Number(env("MEMORY_PORT", "8377")), apiKey: env("MEMORY_API_KEY", ""), consolidationModel: env("CONSOLIDATION_MODEL", ""), + hooksStatePath: env("HOOKS_STATE_PATH") || join(base, "hooks.json"), }; } diff --git a/agent-memory-mesh/src/memory/hooks.ts b/agent-memory-mesh/src/memory/hooks.ts new file mode 100644 index 0000000..b35626b --- /dev/null +++ b/agent-memory-mesh/src/memory/hooks.ts @@ -0,0 +1,149 @@ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { randomUUID } from "node:crypto"; + +export type HookEvent = "search" | "reindex" | "work-memory" | "consolidation" | "feedback"; +export type HookAction = "log"; + +export interface HookCondition { + /** Fire only when the operation took at least this many ms (search/reindex). */ + minLatencyMs?: number; + /** Fire only when the operation encountered an error. */ + onError?: boolean; + /** Fire only when any string field in the payload matches this regex. */ + pattern?: string; +} + +export interface HookRule { + id: string; + name: string; + event: HookEvent; + condition?: HookCondition; + action: HookAction; + enabled: boolean; + createdAt: string; +} + +export interface HookFire { + id: string; + ruleId: string; + ruleName: string; + event: HookEvent; + payload: Record; + firedAt: string; +} + +interface HooksState { + rules: HookRule[]; + history: HookFire[]; +} + +export class HooksEngine { + private state: HooksState; + + constructor(private filePath: string) { + if (existsSync(filePath)) { + try { + this.state = JSON.parse(readFileSync(filePath, "utf8")); + } catch { + this.state = { rules: [], history: [] }; + } + } else { + this.state = { rules: [], history: [] }; + } + } + + private save(): void { + writeFileSync(this.filePath, JSON.stringify(this.state, null, 2)); + } + + addRule(input: Omit): HookRule { + const rule: HookRule = { + ...input, + id: randomUUID(), + createdAt: new Date().toISOString(), + }; + this.state.rules.push(rule); + this.save(); + return rule; + } + + getRule(id: string): HookRule | undefined { + return this.state.rules.find((r) => r.id === id); + } + + listRules(): HookRule[] { + return [...this.state.rules]; + } + + updateRule(id: string, patch: Partial>): HookRule | undefined { + const idx = this.state.rules.findIndex((r) => r.id === id); + if (idx === -1) return undefined; + this.state.rules[idx] = { ...this.state.rules[idx], ...patch }; + this.save(); + return this.state.rules[idx]; + } + + removeRule(id: string): boolean { + const before = this.state.rules.length; + this.state.rules = this.state.rules.filter((r) => r.id !== id); + if (this.state.rules.length !== before) { + this.save(); + return true; + } + return false; + } + + listHistory(ruleId?: string): HookFire[] { + return ruleId ? this.state.history.filter((h) => h.ruleId === ruleId) : [...this.state.history]; + } + + /** + * Fire all enabled rules matching the given event. Evaluates conditions and + * dispatches the configured action. Returns fires that were triggered. + */ + fire(event: HookEvent, payload: Record): HookFire[] { + const fired: HookFire[] = []; + for (const rule of this.state.rules) { + if (!rule.enabled || rule.event !== event) continue; + if (!this.matchesCondition(rule.condition, payload)) continue; + + const hookFire: HookFire = { + id: randomUUID(), + ruleId: rule.id, + ruleName: rule.name, + event, + payload, + firedAt: new Date().toISOString(), + }; + this.dispatch(rule, hookFire); + fired.push(hookFire); + // Keep only last 200 history entries to bound file size + this.state.history.push(hookFire); + if (this.state.history.length > 200) this.state.history.shift(); + } + if (fired.length) this.save(); + return fired; + } + + private matchesCondition(cond: HookCondition | undefined, payload: Record): boolean { + if (!cond) return true; + if (cond.onError && !payload.error) return false; + if (cond.minLatencyMs !== undefined) { + const lat = typeof payload.latencyMs === "number" ? payload.latencyMs : 0; + if (lat < cond.minLatencyMs) return false; + } + if (cond.pattern) { + const re = new RegExp(cond.pattern, "i"); + const haystack = JSON.stringify(payload); + if (!re.test(haystack)) return false; + } + return true; + } + + private dispatch(rule: HookRule, fire: HookFire): void { + // Currently only "log" action is supported — future actions (webhook, etc.) extend here. + if (rule.action === "log") { + console.error(`[hook] ${rule.name} fired on ${fire.event}: ${JSON.stringify(fire.payload)}`); + } + } +} diff --git a/agent-memory-mesh/src/service/engine.ts b/agent-memory-mesh/src/service/engine.ts index 3e39b20..7f48b00 100644 --- a/agent-memory-mesh/src/service/engine.ts +++ b/agent-memory-mesh/src/service/engine.ts @@ -17,8 +17,9 @@ import { Consolidator, type ConsolidationResult } from "../memory/consolidator.j import { ContextGraph, type ContextGraphEntity, type ContextGraphEdge, type EntityType, type NeighborResult } from "../memory/context-graph.js"; import { RetrievalPolicyStore, applyRecencyBoost, type RetrievalPolicy } from "../memory/retrieval-policy.js"; import { FeedbackStore, applyFeedbackScoring, type FeedbackSignal, type NoteFeedback } from "../memory/feedback.js"; +import { HooksEngine, type HookRule, type HookFire, type HookEvent } from "../memory/hooks.js"; -export { type WorkMemoryEntry, type WorkMemoryQuery, type ConsolidationResult, type ContextGraphEntity, type ContextGraphEdge, type EntityType, type NeighborResult, type RetrievalPolicy, type FeedbackSignal, type NoteFeedback }; +export { type WorkMemoryEntry, type WorkMemoryQuery, type ConsolidationResult, type ContextGraphEntity, type ContextGraphEdge, type EntityType, type NeighborResult, type RetrievalPolicy, type FeedbackSignal, type NoteFeedback, type HookRule, type HookFire, type HookEvent }; export interface WikiSummary { entityId: string; @@ -41,6 +42,7 @@ export class MemoryEngine { private graph: ContextGraph; private policies: RetrievalPolicyStore; private feedback: FeedbackStore; + private hooks: HooksEngine; private opened = false; constructor(private cfg: MemoryConfig) { @@ -58,6 +60,7 @@ export class MemoryEngine { }); this.policies = new RetrievalPolicyStore(cfg.policiesPath); this.feedback = new FeedbackStore(cfg.feedbackPath); + this.hooks = new HooksEngine(cfg.hooksStatePath); } /** Open the store lazily, sizing the table from a probe embedding the first time. */ @@ -70,23 +73,41 @@ export class MemoryEngine { /** Hybrid (vector + keyword) search. Optional metadata filter, e.g. type/tags. */ async search(query: string, k = 8, filter?: string): Promise { - await this.ensureOpen(); - const qvec = await this.embedder.embed(query); - return this.store.retrieve(query, qvec, k, filter); + const start = Date.now(); + try { + await this.ensureOpen(); + const qvec = await this.embedder.embed(query); + const hits = await this.store.retrieve(query, qvec, k, filter); + const latencyMs = Date.now() - start; + this.hooks.fire("search", { query, k, filter, latencyMs, resultCount: hits.length }); + return hits; + } catch (err) { + this.hooks.fire("search", { query, k, filter, latencyMs: Date.now() - start, error: String(err) }); + throw err; + } } /** Rebuild the index from the vault. */ async reindex(onProgress?: (m: string) => void): Promise { - const indexer = new Indexer(this.cfg.vaultPath, this.store, this.embedder); - const res = await indexer.indexAll(onProgress); - this.opened = true; // indexer opens the store as part of its run - return res; + const start = Date.now(); + try { + const indexer = new Indexer(this.cfg.vaultPath, this.store, this.embedder); + const res = await indexer.indexAll(onProgress); + this.opened = true; + this.hooks.fire("reindex", { latencyMs: Date.now() - start, notes: res.notes, chunks: res.chunks }); + return res; + } catch (err) { + this.hooks.fire("reindex", { latencyMs: Date.now() - start, error: String(err) }); + throw err; + } } // Work memory — episodic record of agent actions, outputs, and corrections. recordWork(entry: Omit): WorkMemoryEntry { - return this.workMemory.record(entry); + const result = this.workMemory.record(entry); + this.hooks.fire("work-memory", { type: entry.type, sessionId: entry.sessionId, entryId: result.id }); + return result; } queryWork(q: WorkMemoryQuery = {}): WorkMemoryEntry[] { @@ -104,11 +125,15 @@ export class MemoryEngine { // Consolidation — synthesise sessions into vault lesson notes. async consolidateAll(): Promise { - return this.consolidator.consolidateAll(); + const results = await this.consolidator.consolidateAll(); + this.hooks.fire("consolidation", { sessionCount: results.length }); + return results; } async consolidateSession(sessionId: string): Promise { - return this.consolidator.consolidateSession(sessionId); + const result = await this.consolidator.consolidateSession(sessionId); + this.hooks.fire("consolidation", { sessionId, entryCount: result.entryCount }); + return result; } // Context graph — entities, edges, and LLM-wiki summaries. @@ -226,9 +251,11 @@ export class MemoryEngine { vote: "up" | "down", opts: { sessionId?: string; query?: string; note?: string; workMemoryEntryId?: string } = {} ): FeedbackSignal { - return vote === "up" + const signal = vote === "up" ? this.feedback.upvote(notePath, opts) : this.feedback.downvote(notePath, opts); + this.hooks.fire("feedback", { notePath, vote, signalId: signal.id }); + return signal; } listFeedbackSignals(notePath?: string): FeedbackSignal[] { @@ -267,6 +294,60 @@ export class MemoryEngine { return { processed: processedIds.length, signals }; } + // Hooks / alerting + + addHookRule(input: Omit): HookRule { + return this.hooks.addRule(input); + } + + getHookRule(id: string): HookRule | undefined { + return this.hooks.getRule(id); + } + + listHookRules(): HookRule[] { + return this.hooks.listRules(); + } + + updateHookRule(id: string, patch: Partial>): HookRule | undefined { + return this.hooks.updateRule(id, patch); + } + + removeHookRule(id: string): boolean { + return this.hooks.removeRule(id); + } + + listHookHistory(ruleId?: string): HookFire[] { + return this.hooks.listHistory(ruleId); + } + + fireHook(event: HookEvent, payload: Record): HookFire[] { + return this.hooks.fire(event, payload); + } + + // Inspect / stats + + getStats(): { + workMemory: { total: number; sessions: number }; + graph: { entities: number; edges: number }; + feedback: { signals: number; upvotes: number; downvotes: number }; + policies: { total: number }; + hooks: { rules: number }; + } { + const entries = this.workMemory.query({}); + const sessions = new Set(entries.map((e) => e.sessionId)).size; + const edges = this.graph.getEdges().length; + const signals = this.feedback.listSignals(); + const upvotes = signals.filter((s) => s.vote === "up").length; + const downvotes = signals.filter((s) => s.vote === "down").length; + return { + workMemory: { total: entries.length, sessions }, + graph: { entities: this.listEntities().length, edges }, + feedback: { signals: signals.length, upvotes, downvotes }, + policies: { total: this.listPolicies().length }, + hooks: { rules: this.listHookRules().length }, + }; + } + private collectWikis(query: string, types?: EntityType[]): WikiSummary[] { const matches = this.graph.findByName(query); const candidates = types ? matches.filter((e) => types.includes(e.type)) : matches; diff --git a/agent-memory-mesh/src/service/http.ts b/agent-memory-mesh/src/service/http.ts index f1750c1..53c6508 100644 --- a/agent-memory-mesh/src/service/http.ts +++ b/agent-memory-mesh/src/service/http.ts @@ -279,6 +279,54 @@ export function startHttp( return send(res, 200, { entity }); } + // Inspect + if (req.method === "GET" && url.pathname === "/inspect/stats") { + return send(res, 200, { stats: engine.getStats() }); + } + + // Hooks + if (req.method === "GET" && url.pathname === "/hooks") { + return send(res, 200, { rules: engine.listHookRules() }); + } + if (req.method === "POST" && url.pathname === "/hooks") { + const body = await readJson(req); + if (!body.name) return send(res, 400, { error: "name is required" }); + if (!body.event) return send(res, 400, { error: "event is required" }); + if (!body.action) return send(res, 400, { error: "action is required" }); + const rule = engine.addHookRule({ + name: body.name, + event: body.event, + action: body.action, + condition: body.condition, + enabled: body.enabled !== false, + }); + return send(res, 201, { rule }); + } + const hookIdMatch = url.pathname.match(/^\/hooks\/([^/]+)$/); + if (hookIdMatch) { + const id = decodeURIComponent(hookIdMatch[1]); + if (req.method === "GET") { + const rule = engine.getHookRule(id); + if (!rule) return send(res, 404, { error: "hook rule not found" }); + return send(res, 200, { rule }); + } + if (req.method === "PATCH") { + const body = await readJson(req); + const rule = engine.updateHookRule(id, body); + if (!rule) return send(res, 404, { error: "hook rule not found" }); + return send(res, 200, { rule }); + } + if (req.method === "DELETE") { + const ok = engine.removeHookRule(id); + return send(res, ok ? 200 : 404, { ok }); + } + } + const hookHistoryMatch = url.pathname.match(/^\/hooks\/([^/]+)\/history$/); + if (req.method === "GET" && hookHistoryMatch) { + const id = decodeURIComponent(hookHistoryMatch[1]); + return send(res, 200, { history: engine.listHookHistory(id) }); + } + return send(res, 404, { error: "not found" }); } catch (err) { return send(res, 500, { error: String(err) }); diff --git a/agent-memory-mesh/src/service/mcp.ts b/agent-memory-mesh/src/service/mcp.ts index 6560f08..90aa311 100644 --- a/agent-memory-mesh/src/service/mcp.ts +++ b/agent-memory-mesh/src/service/mcp.ts @@ -359,6 +359,81 @@ export async function startMcpStdio(cfg: MemoryConfig, engine: MemoryEngine): Pr } ); + server.registerTool( + "inspect_stats", + { + title: "Inspect memory stats", + description: + "Return aggregate statistics about the current memory state: work memory entry count, " + + "graph entity/edge counts, feedback signal counts, policy count, and hook rule count.", + inputSchema: {}, + }, + async () => { + const stats = engine.getStats(); + const lines = [ + `Work memory: ${stats.workMemory.total} entries across ${stats.workMemory.sessions} session(s)`, + `Graph: ${stats.graph.entities} entities, ${stats.graph.edges} edges`, + `Feedback: ${stats.feedback.signals} signals (${stats.feedback.upvotes} up / ${stats.feedback.downvotes} down)`, + `Policies: ${stats.policies.total}`, + `Hook rules: ${stats.hooks.rules}`, + ]; + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + ); + + server.registerTool( + "add_hook_rule", + { + title: "Add hook rule", + description: + "Register an alert rule that fires when a memory event occurs. " + + "Supported events: search, reindex, work-memory, consolidation, feedback. " + + "The 'log' action writes a line to stderr when triggered.", + inputSchema: { + name: z.string().describe("Human-readable rule name."), + event: z + .enum(["search", "reindex", "work-memory", "consolidation", "feedback"]) + .describe("Event type to listen for."), + action: z.enum(["log"]).describe("Action to take when the rule fires."), + enabled: z.boolean().optional().describe("Whether the rule is active (default true)."), + condition: z + .object({ + minLatencyMs: z.number().optional().describe("Fire only when latency >= this (search/reindex)."), + onError: z.boolean().optional().describe("Fire only on errors."), + pattern: z.string().optional().describe("Regex pattern — fire only when payload matches."), + }) + .optional() + .describe("Optional condition filter."), + }, + }, + async (input) => { + const rule = engine.addHookRule({ + name: input.name, + event: input.event, + action: input.action, + enabled: input.enabled !== false, + condition: input.condition as any, + }); + return { content: [{ type: "text", text: JSON.stringify(rule, null, 2) }] }; + } + ); + + server.registerTool( + "list_hook_rules", + { + title: "List hook rules", + description: "List all registered hook/alert rules.", + inputSchema: {}, + }, + async () => { + const rules = engine.listHookRules(); + const text = rules.length + ? rules.map((r) => `[${r.enabled ? "on" : "off"}] ${r.name} — ${r.event} → ${r.action} (${r.id})`).join("\n") + : "No hook rules configured."; + return { content: [{ type: "text", text }] }; + } + ); + const transport = new StdioServerTransport(); await server.connect(transport); console.error("[mcp] agent-memory-mesh stdio server ready"); diff --git a/agent-memory-mesh/test/hooks.test.ts b/agent-memory-mesh/test/hooks.test.ts new file mode 100644 index 0000000..2e1b5b4 --- /dev/null +++ b/agent-memory-mesh/test/hooks.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { HooksEngine } from "../src/memory/hooks.js"; + +function makeTmpFile(): string { + const dir = join(tmpdir(), "hooks-test-" + randomUUID()); + mkdirSync(dir, { recursive: true }); + return join(dir, "hooks.json"); +} + +export async function runHooksTests(): Promise { + console.log(" [hooks] running..."); + + const filePath = makeTmpFile(); + const engine = new HooksEngine(filePath); + + // Empty state + assert.deepEqual(engine.listRules(), []); + assert.deepEqual(engine.listHistory(), []); + + // addRule + const rule = engine.addRule({ + name: "slow-search", + event: "search", + action: "log", + condition: { minLatencyMs: 500 }, + enabled: true, + }); + assert.ok(rule.id); + assert.ok(rule.createdAt); + assert.equal(rule.name, "slow-search"); + + // listRules + assert.equal(engine.listRules().length, 1); + + // getRule + assert.equal(engine.getRule(rule.id)?.name, "slow-search"); + assert.equal(engine.getRule("nope"), undefined); + + // fire — below threshold, should not trigger + let fired = engine.fire("search", { query: "test", latencyMs: 100 }); + assert.equal(fired.length, 0); + assert.equal(engine.listHistory().length, 0); + + // fire — at threshold, should trigger + fired = engine.fire("search", { query: "test", latencyMs: 600 }); + assert.equal(fired.length, 1); + assert.equal(fired[0].ruleId, rule.id); + assert.equal(fired[0].event, "search"); + assert.equal(engine.listHistory().length, 1); + + // wrong event does not fire + fired = engine.fire("reindex", { latencyMs: 1000 }); + assert.equal(fired.length, 0); + + // disabled rule does not fire + engine.updateRule(rule.id, { enabled: false }); + fired = engine.fire("search", { query: "x", latencyMs: 1000 }); + assert.equal(fired.length, 0); + + // re-enable + engine.updateRule(rule.id, { enabled: true }); + fired = engine.fire("search", { query: "x", latencyMs: 1000 }); + assert.equal(fired.length, 1); + + // onError condition + const errRule = engine.addRule({ name: "on-err", event: "search", action: "log", condition: { onError: true }, enabled: true }); + fired = engine.fire("search", { query: "x", latencyMs: 0 }); // no error field → should not fire errRule + const errFires = fired.filter((f) => f.ruleId === errRule.id); + assert.equal(errFires.length, 0); + fired = engine.fire("search", { query: "x", latencyMs: 0, error: "timeout" }); + const errFires2 = fired.filter((f) => f.ruleId === errRule.id); + assert.equal(errFires2.length, 1); + + // pattern condition + const patRule = engine.addRule({ name: "pattern-match", event: "feedback", action: "log", condition: { pattern: "importantNote" }, enabled: true }); + fired = engine.fire("feedback", { notePath: "importantNote.md", vote: "down" }); + assert.equal(fired.filter((f) => f.ruleId === patRule.id).length, 1); + fired = engine.fire("feedback", { notePath: "other.md", vote: "down" }); + assert.equal(fired.filter((f) => f.ruleId === patRule.id).length, 0); + + // listHistory by ruleId + const hist = engine.listHistory(rule.id); + assert.ok(hist.length >= 2); + assert.ok(hist.every((h) => h.ruleId === rule.id)); + + // removeRule + const ok = engine.removeRule(rule.id); + assert.equal(ok, true); + assert.equal(engine.getRule(rule.id), undefined); + assert.equal(engine.removeRule("nope"), false); + + // persistence across instances + const rule2 = engine.addRule({ name: "persist-test", event: "consolidation", action: "log", enabled: true }); + const engine2 = new HooksEngine(filePath); + assert.ok(engine2.getRule(rule2.id)); + assert.equal(engine2.getRule(rule2.id)?.name, "persist-test"); + + // history bounded to 200 entries (fire many times) + const bulkRule = engine2.addRule({ name: "bulk", event: "work-memory", action: "log", enabled: true }); + for (let i = 0; i < 210; i++) { + engine2.fire("work-memory", { type: "action", sessionId: "s1", entryId: String(i) }); + } + const fullHistory = engine2.listHistory(); + assert.ok(fullHistory.length <= 200); + + console.log(" [hooks] all tests passed"); +} diff --git a/agent-memory-mesh/test/run-tests.ts b/agent-memory-mesh/test/run-tests.ts index d1ca5c2..02381f5 100644 --- a/agent-memory-mesh/test/run-tests.ts +++ b/agent-memory-mesh/test/run-tests.ts @@ -5,6 +5,7 @@ import { runConsolidatorTests } from "./consolidator.test.js"; import { runContextGraphTests } from "./context-graph.test.js"; import { runRetrievalPolicyTests } from "./retrieval-policy.test.js"; import { runFeedbackTests } from "./feedback.test.js"; +import { runHooksTests } from "./hooks.test.js"; async function main() { let exitCode = 0; @@ -14,6 +15,7 @@ async function main() { { name: "ContextGraph", fn: runContextGraphTests }, { name: "RetrievalPolicy", fn: runRetrievalPolicyTests }, { name: "Feedback", fn: runFeedbackTests }, + { name: "Hooks", fn: runHooksTests }, ]; for (const suite of suites) {