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
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 the mesh node registry. */
nodeRegistryPath: 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", ""),
nodeRegistryPath: env("NODE_REGISTRY_PATH") || join(base, "node-registry.json"),
};
}
115 changes: 115 additions & 0 deletions agent-memory-mesh/src/connectors/onedrive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* OneDriveConnector
* -----------------
* Fetches file listings and metadata from Microsoft OneDrive via the
* Microsoft Graph API. Requires a valid delegated access token with at
* least Files.Read scope.
*
* Design rules (from CLAUDE.md):
* - Graph API only — no browser automation.
* - Does NOT auto-index or auto-write. Returns raw item metadata
* for the caller to queue for human-approved ingestion.
* - The access token is passed per-call so nothing is stored at rest.
*/

const GRAPH_BASE = "https://graph.microsoft.com/v1.0";

export interface OneDriveItem {
id: string;
name: string;
size: number;
lastModifiedDateTime: string;
webUrl: string;
/** Present on files (absent on folders). */
file?: { mimeType: string };
/** Present on folders. */
folder?: { childCount: number };
/** @microsoft.graph.downloadUrl — pre-authenticated short-lived URL. */
downloadUrl?: string;
}

export interface OneDriveListing {
driveId: string;
folderId: string | null;
items: OneDriveItem[];
nextLink?: string;
}

export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;

export class OneDriveConnector {
private fetch: FetchFn;

constructor(
private accessToken: string,
fetchImpl?: FetchFn
) {
// Allow injecting a mock fetch in tests; default to globalThis.fetch (Node 18+).
this.fetch = fetchImpl ?? ((url, init) => globalThis.fetch(url, init));
}

private authHeaders(): HeadersInit {
return { Authorization: `Bearer ${this.accessToken}` };
}

/**
* List children of a OneDrive folder.
* @param driveId 'me' for the signed-in user's drive, or a shared drive ID.
* @param folderId Item ID of the folder, or null/'' for the root.
* @param top Page size (default 50, max 1000).
*/
async listItems(driveId: string, folderId: string | null, top = 50): Promise<OneDriveListing> {
const base = folderId
? `${GRAPH_BASE}/drives/${driveId}/items/${folderId}/children`
: `${GRAPH_BASE}/drives/${driveId}/root/children`;
const url = `${base}?$top=${top}&$select=id,name,size,lastModifiedDateTime,webUrl,file,folder,@microsoft.graph.downloadUrl`;

const res = await this.fetch(url, { headers: this.authHeaders() });
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph API error ${res.status}: ${text}`);
}
const json = await res.json() as { value: OneDriveItem[]; "@odata.nextLink"?: string };
return {
driveId,
folderId: folderId ?? null,
items: json.value,
nextLink: json["@odata.nextLink"],
};
}

/**
* Get a single item by ID.
*/
async getItem(driveId: string, itemId: string): Promise<OneDriveItem> {
const url = `${GRAPH_BASE}/drives/${driveId}/items/${itemId}?$select=id,name,size,lastModifiedDateTime,webUrl,file,folder,@microsoft.graph.downloadUrl`;
const res = await this.fetch(url, { headers: this.authHeaders() });
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph API error ${res.status}: ${text}`);
}
return res.json() as Promise<OneDriveItem>;
}

/**
* List all items in a folder recursively, up to maxDepth levels.
* Returns a flat array of file items only (folders are traversed but not returned).
*/
async listFilesRecursive(driveId: string, folderId: string | null, maxDepth = 3): Promise<OneDriveItem[]> {
const files: OneDriveItem[] = [];
const stack: { id: string | null; depth: number }[] = [{ id: folderId, depth: 0 }];

while (stack.length) {
const { id, depth } = stack.pop()!;
const listing = await this.listItems(driveId, id);
for (const item of listing.items) {
if (item.file) {
files.push(item);
} else if (item.folder && depth < maxDepth) {
stack.push({ id: item.id, depth: depth + 1 });
}
}
}
return files;
}
}
129 changes: 129 additions & 0 deletions agent-memory-mesh/src/memory/node-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { randomUUID } from "node:crypto";

export type NodeStatus = "online" | "offline";

export interface MeshNode {
id: string;
name: string;
/** Tailscale IP or hostname used to reach the node's mesh runner. */
address: string;
capabilities: string[];
status: NodeStatus;
registeredAt: string;
lastHeartbeatAt: string;
/** Arbitrary metadata (OS, runner version, etc.). */
metadata?: Record<string, unknown>;
}

export interface NodeRegistration {
name: string;
address: string;
capabilities: string[];
metadata?: Record<string, unknown>;
}

interface RegistryState {
nodes: MeshNode[];
}

export class NodeRegistry {
private state: RegistryState;

constructor(private filePath: string) {
if (existsSync(filePath)) {
try {
this.state = JSON.parse(readFileSync(filePath, "utf8"));
} catch {
this.state = { nodes: [] };
}
} else {
this.state = { nodes: [] };
}
}

private save(): void {
writeFileSync(this.filePath, JSON.stringify(this.state, null, 2));
}

/**
* Register a node. If a node with the same name already exists, updates it
* in place (idempotent re-registration). Returns the node record.
*/
register(input: NodeRegistration): MeshNode {
const now = new Date().toISOString();
const existing = this.state.nodes.find((n) => n.name === input.name);
if (existing) {
existing.address = input.address;
existing.capabilities = input.capabilities;
existing.status = "online";
existing.lastHeartbeatAt = now;
if (input.metadata) existing.metadata = input.metadata;
this.save();
return existing;
}
const node: MeshNode = {
id: randomUUID(),
name: input.name,
address: input.address,
capabilities: input.capabilities,
status: "online",
registeredAt: now,
lastHeartbeatAt: now,
metadata: input.metadata,
};
this.state.nodes.push(node);
this.save();
return node;
}

get(id: string): MeshNode | undefined {
return this.state.nodes.find((n) => n.id === id);
}

getByName(name: string): MeshNode | undefined {
return this.state.nodes.find((n) => n.name === name);
}

list(filter?: { status?: NodeStatus; capability?: string }): MeshNode[] {
let nodes = [...this.state.nodes];
if (filter?.status) nodes = nodes.filter((n) => n.status === filter.status);
if (filter?.capability) nodes = nodes.filter((n) => n.capabilities.includes(filter.capability!));
return nodes;
}

/** Update lastHeartbeatAt and set status to online. Returns false if node not found. */
heartbeat(id: string): boolean {
const node = this.state.nodes.find((n) => n.id === id);
if (!node) return false;
node.lastHeartbeatAt = new Date().toISOString();
node.status = "online";
this.save();
return true;
}

/** Mark a node as offline. Returns false if node not found. */
deregister(id: string): boolean {
const node = this.state.nodes.find((n) => n.id === id);
if (!node) return false;
node.status = "offline";
this.save();
return true;
}

/** Hard-delete a node from the registry. Returns false if not found. */
remove(id: string): boolean {
const before = this.state.nodes.length;
this.state.nodes = this.state.nodes.filter((n) => n.id !== id);
if (this.state.nodes.length !== before) {
this.save();
return true;
}
return false;
}

/** Find nodes that advertise a given capability. */
findByCapability(capability: string): MeshNode[] {
return this.state.nodes.filter((n) => n.capabilities.includes(capability) && n.status === "online");
}
}
39 changes: 38 additions & 1 deletion 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 { NodeRegistry, type MeshNode, type NodeRegistration, type NodeStatus } from "../memory/node-registry.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 MeshNode, type NodeStatus };

export interface WikiSummary {
entityId: string;
Expand All @@ -41,6 +42,7 @@ export class MemoryEngine {
private graph: ContextGraph;
private policies: RetrievalPolicyStore;
private feedback: FeedbackStore;
private nodeRegistry: NodeRegistry;
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.nodeRegistry = new NodeRegistry(cfg.nodeRegistryPath);
}

/** Open the store lazily, sizing the table from a probe embedding the first time. */
Expand Down Expand Up @@ -267,6 +270,40 @@ export class MemoryEngine {
return { processed: processedIds.length, signals };
}

// Node registry — mesh node discovery and heartbeat

registerNode(input: NodeRegistration): MeshNode {
return this.nodeRegistry.register(input);
}

getNode(id: string): MeshNode | undefined {
return this.nodeRegistry.get(id);
}

getNodeByName(name: string): MeshNode | undefined {
return this.nodeRegistry.getByName(name);
}

listNodes(filter?: { status?: NodeStatus; capability?: string }): MeshNode[] {
return this.nodeRegistry.list(filter);
}

nodeHeartbeat(id: string): boolean {
return this.nodeRegistry.heartbeat(id);
}

deregisterNode(id: string): boolean {
return this.nodeRegistry.deregister(id);
}

removeNode(id: string): boolean {
return this.nodeRegistry.remove(id);
}

findNodesByCapability(capability: string): MeshNode[] {
return this.nodeRegistry.findByCapability(capability);
}

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
54 changes: 54 additions & 0 deletions agent-memory-mesh/src/service/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,60 @@ export function startHttp(
return send(res, 200, { entity });
}

// Node registry
if (req.method === "POST" && url.pathname === "/nodes/register") {
const body = await readJson(req);
if (!body.name) return send(res, 400, { error: "name is required" });
if (!body.address) return send(res, 400, { error: "address is required" });
if (!Array.isArray(body.capabilities)) return send(res, 400, { error: "capabilities (array) is required" });
const node = engine.registerNode({ name: body.name, address: body.address, capabilities: body.capabilities, metadata: body.metadata });
return send(res, 201, { node });
}
if (req.method === "GET" && url.pathname === "/nodes") {
const q = url.searchParams;
const nodes = engine.listNodes({
status: (q.get("status") as any) ?? undefined,
capability: q.get("capability") ?? undefined,
});
return send(res, 200, { nodes });
}
const nodeIdMatch = url.pathname.match(/^\/nodes\/([^/]+)$/);
if (nodeIdMatch) {
const id = decodeURIComponent(nodeIdMatch[1]);
if (req.method === "GET") {
const node = engine.getNode(id);
if (!node) return send(res, 404, { error: "node not found" });
return send(res, 200, { node });
}
if (req.method === "DELETE") {
const ok = engine.removeNode(id);
return send(res, ok ? 200 : 404, { ok });
}
}
const heartbeatMatch = url.pathname.match(/^\/nodes\/([^/]+)\/heartbeat$/);
if (req.method === "POST" && heartbeatMatch) {
const id = decodeURIComponent(heartbeatMatch[1]);
const ok = engine.nodeHeartbeat(id);
return send(res, ok ? 200 : 404, { ok });
}
const deregisterMatch = url.pathname.match(/^\/nodes\/([^/]+)\/deregister$/);
if (req.method === "POST" && deregisterMatch) {
const id = decodeURIComponent(deregisterMatch[1]);
const ok = engine.deregisterNode(id);
return send(res, ok ? 200 : 404, { ok });
}

// OneDrive connector — list items (requires caller to supply access token)
if (req.method === "POST" && url.pathname === "/connectors/onedrive/list") {
const { OneDriveConnector } = await import("../connectors/onedrive.js");
const body = await readJson(req);
if (!body.accessToken) return send(res, 400, { error: "accessToken is required" });
if (!body.driveId) return send(res, 400, { error: "driveId is required" });
const connector = new OneDriveConnector(body.accessToken);
const listing = await connector.listItems(body.driveId, body.folderId ?? null, body.top ?? 50);
return send(res, 200, listing);
}

return send(res, 404, { error: "not found" });
} catch (err) {
return send(res, 500, { error: String(err) });
Expand Down
Loading