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
1 change: 1 addition & 0 deletions agent-memory-mesh/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
29 changes: 29 additions & 0 deletions agent-memory-mesh/src/cli/inspect.ts
Original file line number Diff line number Diff line change
@@ -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); });
3 changes: 3 additions & 0 deletions agent-memory-mesh/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"),
};
}
149 changes: 149 additions & 0 deletions agent-memory-mesh/src/memory/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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, "id" | "createdAt">): 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<Omit<HookRule, "id" | "createdAt">>): 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<string, unknown>): 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<string, unknown>): 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)}`);
}
}
}
105 changes: 93 additions & 12 deletions agent-memory-mesh/src/service/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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. */
Expand All @@ -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<RetrievalHit[]> {
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<IndexResult> {
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, "id" | "timestamp">): 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[] {
Expand All @@ -104,11 +125,15 @@ export class MemoryEngine {
// Consolidation — synthesise sessions into vault lesson notes.

async consolidateAll(): Promise<ConsolidationResult[]> {
return this.consolidator.consolidateAll();
const results = await this.consolidator.consolidateAll();
this.hooks.fire("consolidation", { sessionCount: results.length });
return results;
}

async consolidateSession(sessionId: string): Promise<ConsolidationResult> {
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.
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -267,6 +294,60 @@ export class MemoryEngine {
return { processed: processedIds.length, signals };
}

// Hooks / alerting

addHookRule(input: Omit<HookRule, "id" | "createdAt">): 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<Omit<HookRule, "id" | "createdAt">>): 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<string, unknown>): 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;
Expand Down
Loading