diff --git a/agent-memory-mesh/src/config.ts b/agent-memory-mesh/src/config.ts index 9c507dc..98dfe05 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 the mesh node registry. */ + nodeRegistryPath: 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", ""), + nodeRegistryPath: env("NODE_REGISTRY_PATH") || join(base, "node-registry.json"), }; } diff --git a/agent-memory-mesh/src/connectors/onedrive.ts b/agent-memory-mesh/src/connectors/onedrive.ts new file mode 100644 index 0000000..0b309b5 --- /dev/null +++ b/agent-memory-mesh/src/connectors/onedrive.ts @@ -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; + +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 { + 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 { + 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; + } + + /** + * 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 { + 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; + } +} diff --git a/agent-memory-mesh/src/memory/node-registry.ts b/agent-memory-mesh/src/memory/node-registry.ts new file mode 100644 index 0000000..9f091e8 --- /dev/null +++ b/agent-memory-mesh/src/memory/node-registry.ts @@ -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; +} + +export interface NodeRegistration { + name: string; + address: string; + capabilities: string[]; + metadata?: Record; +} + +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"); + } +} diff --git a/agent-memory-mesh/src/service/engine.ts b/agent-memory-mesh/src/service/engine.ts index 3e39b20..68ecdf5 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 { 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; @@ -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) { @@ -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. */ @@ -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; diff --git a/agent-memory-mesh/src/service/http.ts b/agent-memory-mesh/src/service/http.ts index f1750c1..e4dd243 100644 --- a/agent-memory-mesh/src/service/http.ts +++ b/agent-memory-mesh/src/service/http.ts @@ -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) }); diff --git a/agent-memory-mesh/src/service/mcp.ts b/agent-memory-mesh/src/service/mcp.ts index 6560f08..5614ef8 100644 --- a/agent-memory-mesh/src/service/mcp.ts +++ b/agent-memory-mesh/src/service/mcp.ts @@ -359,6 +359,72 @@ export async function startMcpStdio(cfg: MemoryConfig, engine: MemoryEngine): Pr } ); + server.registerTool( + "register_node", + { + title: "Register mesh node", + description: + "Register or re-register a mesh runner node so the memory service knows it's online. " + + "Re-registering an existing name updates the address and capabilities in place.", + inputSchema: { + name: z.string().describe("Unique node name (e.g. 'macbook-hq', 'windows-laptop')."), + address: z.string().describe("Tailscale IP or hostname of the node's mesh runner."), + capabilities: z.array(z.string()).describe("List of capabilities this node offers, e.g. ['m365', 'memory', 'shell']."), + metadata: z.record(z.unknown()).optional().describe("Optional extra info (OS, runner version, etc.)."), + }, + }, + async ({ name, address, capabilities, metadata }) => { + const node = engine.registerNode({ name, address, capabilities, metadata }); + return { content: [{ type: "text", text: JSON.stringify(node, null, 2) }] }; + } + ); + + server.registerTool( + "list_nodes", + { + title: "List mesh nodes", + description: "List all registered mesh nodes, optionally filtered by status or capability.", + inputSchema: { + status: z.enum(["online", "offline"]).optional().describe("Filter by node status."), + capability: z.string().optional().describe("Filter to nodes that offer this capability."), + }, + }, + async ({ status, capability }) => { + const nodes = engine.listNodes({ status, capability }); + const text = nodes.length + ? nodes.map((n) => `[${n.status}] ${n.name} (${n.address}) — ${n.capabilities.join(", ")}`).join("\n") + : "No nodes found."; + return { content: [{ type: "text", text }] }; + } + ); + + server.registerTool( + "list_onedrive_files", + { + title: "List OneDrive files", + description: + "List files in a OneDrive folder via Microsoft Graph API. " + + "Returns file metadata (name, size, lastModified, downloadUrl). " + + "Does NOT auto-ingest — files must be explicitly queued for human-approved indexing. " + + "Requires a valid delegated access token with Files.Read scope.", + inputSchema: { + accessToken: z.string().describe("Microsoft Graph API access token (Files.Read scope)."), + driveId: z.string().describe("Drive ID — use 'me' for the signed-in user's personal drive."), + folderId: z.string().optional().describe("Item ID of the folder to list. Omit for drive root."), + top: z.number().int().positive().optional().describe("Max items to return per page (default 50)."), + }, + }, + async ({ accessToken, driveId, folderId, top }) => { + const { OneDriveConnector } = await import("../connectors/onedrive.js"); + const connector = new OneDriveConnector(accessToken); + const listing = await connector.listItems(driveId, folderId ?? null, top ?? 50); + const text = listing.items.length + ? listing.items.map((f) => `${f.file ? "📄" : "📁"} ${f.name} (${f.size ?? 0} bytes, ${f.lastModifiedDateTime})`).join("\n") + : "No items found."; + 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/node-registry.test.ts b/agent-memory-mesh/test/node-registry.test.ts new file mode 100644 index 0000000..9b5a8b5 --- /dev/null +++ b/agent-memory-mesh/test/node-registry.test.ts @@ -0,0 +1,110 @@ +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 { NodeRegistry } from "../src/memory/node-registry.js"; + +function makeTmpFile(): string { + const dir = join(tmpdir(), "node-reg-test-" + randomUUID()); + mkdirSync(dir, { recursive: true }); + return join(dir, "registry.json"); +} + +export async function runNodeRegistryTests(): Promise { + console.log(" [node-registry] running..."); + + const filePath = makeTmpFile(); + const registry = new NodeRegistry(filePath); + + // Empty registry + assert.deepEqual(registry.list(), []); + + // register a node + const node = registry.register({ + name: "macbook-hq", + address: "100.64.0.1", + capabilities: ["local-model", "memory"], + metadata: { os: "macOS" }, + }); + assert.ok(node.id); + assert.equal(node.name, "macbook-hq"); + assert.equal(node.status, "online"); + assert.ok(node.registeredAt); + assert.ok(node.lastHeartbeatAt); + + // list returns the node + assert.equal(registry.list().length, 1); + + // get by id + assert.equal(registry.get(node.id)?.name, "macbook-hq"); + assert.equal(registry.get("no-such-id"), undefined); + + // getByName + assert.equal(registry.getByName("macbook-hq")?.id, node.id); + assert.equal(registry.getByName("nope"), undefined); + + // re-register is idempotent (updates in place, same id) + const updated = registry.register({ + name: "macbook-hq", + address: "100.64.0.2", + capabilities: ["local-model", "memory", "shell"], + }); + assert.equal(updated.id, node.id); + assert.equal(updated.address, "100.64.0.2"); + assert.equal(registry.list().length, 1); + + // register second node + const node2 = registry.register({ + name: "windows-laptop", + address: "100.64.0.3", + capabilities: ["m365", "memory"], + }); + + // filter by capability + const memNodes = registry.findByCapability("memory"); + assert.equal(memNodes.length, 2); + const m365Nodes = registry.findByCapability("m365"); + assert.equal(m365Nodes.length, 1); + assert.equal(m365Nodes[0].name, "windows-laptop"); + + // filter by status + assert.equal(registry.list({ status: "online" }).length, 2); + assert.equal(registry.list({ status: "offline" }).length, 0); + + // heartbeat + const hbOk = registry.heartbeat(node.id); + assert.equal(hbOk, true); + assert.equal(registry.heartbeat("nope"), false); + + // deregister marks offline but keeps the record + const deOk = registry.deregister(node.id); + assert.equal(deOk, true); + assert.equal(registry.get(node.id)?.status, "offline"); + assert.equal(registry.list({ status: "offline" }).length, 1); + + // offline nodes not returned by findByCapability + const memOnline = registry.findByCapability("memory"); + assert.equal(memOnline.length, 1); // only node2 is online + + // deregister non-existent + assert.equal(registry.deregister("nope"), false); + + // remove hard-deletes + const rmOk = registry.remove(node.id); + assert.equal(rmOk, true); + assert.equal(registry.get(node.id), undefined); + assert.equal(registry.list().length, 1); + assert.equal(registry.remove("nope"), false); + + // persistence across instances + const registry2 = new NodeRegistry(filePath); + assert.equal(registry2.list().length, 1); + assert.equal(registry2.get(node2.id)?.name, "windows-laptop"); + + // filter by capability via list() + const capList = registry2.list({ capability: "m365" }); + assert.equal(capList.length, 1); + + console.log(" [node-registry] all tests passed"); +} diff --git a/agent-memory-mesh/test/onedrive.test.ts b/agent-memory-mesh/test/onedrive.test.ts new file mode 100644 index 0000000..370c6b4 --- /dev/null +++ b/agent-memory-mesh/test/onedrive.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { OneDriveConnector, type OneDriveItem, type FetchFn } from "../src/connectors/onedrive.js"; + +function makeItem(overrides: Partial = {}): OneDriveItem { + return { + id: "item-1", + name: "document.docx", + size: 1024, + lastModifiedDateTime: "2026-06-01T12:00:00Z", + webUrl: "https://onedrive.example.com/item-1", + file: { mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, + ...overrides, + }; +} + +function mockFetch(responses: Map): FetchFn { + return async (url: string) => { + const key = url.split("?")[0]; + const match = responses.get(key); + if (!match) throw new Error(`Unexpected fetch: ${url}`); + return { + ok: match.ok, + status: match.ok ? 200 : 400, + text: async () => JSON.stringify(match.body), + json: async () => match.body, + } as Response; + }; +} + +export async function runOneDriveTests(): Promise { + console.log(" [onedrive] running..."); + + const driveId = "me"; + const folderId = "folder-abc"; + const items: OneDriveItem[] = [ + makeItem({ id: "f1", name: "proposal.docx" }), + makeItem({ id: "f2", name: "budget.xlsx", file: { mimeType: "application/vnd.ms-excel" } }), + { id: "fold1", name: "Archive", size: 0, lastModifiedDateTime: "2026-01-01T00:00:00Z", webUrl: "https://x", folder: { childCount: 3 } }, + ]; + + const responses = new Map([ + [ + `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${folderId}/children`, + { ok: true, body: { value: items } }, + ], + [ + `https://graph.microsoft.com/v1.0/drives/${driveId}/items/f1`, + { ok: true, body: items[0] }, + ], + // For recursive listing: root listing returns a folder + files + [ + `https://graph.microsoft.com/v1.0/drives/${driveId}/root/children`, + { ok: true, body: { value: items } }, + ], + // The folder child listing returns only files + [ + `https://graph.microsoft.com/v1.0/drives/${driveId}/items/fold1/children`, + { ok: true, body: { value: [makeItem({ id: "f3", name: "old.docx" })] } }, + ], + ]); + + const connector = new OneDriveConnector("fake-token", mockFetch(responses)); + + // listItems with folderId + const listing = await connector.listItems(driveId, folderId); + assert.equal(listing.driveId, driveId); + assert.equal(listing.folderId, folderId); + assert.equal(listing.items.length, 3); + assert.equal(listing.items[0].name, "proposal.docx"); + + // listItems with null folderId (root) + const rootListing = await connector.listItems(driveId, null); + assert.equal(rootListing.folderId, null); + assert.equal(rootListing.items.length, 3); + + // getItem + const item = await connector.getItem(driveId, "f1"); + assert.equal(item.name, "proposal.docx"); + + // listFilesRecursive: root has 2 files + 1 folder; folder has 1 file → total 3 + const allFiles = await connector.listFilesRecursive(driveId, null); + assert.equal(allFiles.length, 3); + assert.ok(allFiles.every((f) => f.file)); // only files, no folders + assert.ok(allFiles.some((f) => f.name === "old.docx")); // from subfolder + + // error response throws + const errResponses = new Map([ + [ + `https://graph.microsoft.com/v1.0/drives/bad-drive/items/x/children`, + { ok: false, body: { error: { code: "itemNotFound" } } }, + ], + ]); + const errConnector = new OneDriveConnector("fake-token", mockFetch(errResponses)); + await assert.rejects( + () => errConnector.listItems("bad-drive", "x"), + /Graph API error 400/ + ); + + console.log(" [onedrive] all tests passed"); +} diff --git a/agent-memory-mesh/test/run-tests.ts b/agent-memory-mesh/test/run-tests.ts index d1ca5c2..05b0a20 100644 --- a/agent-memory-mesh/test/run-tests.ts +++ b/agent-memory-mesh/test/run-tests.ts @@ -5,6 +5,8 @@ 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 { runNodeRegistryTests } from "./node-registry.test.js"; +import { runOneDriveTests } from "./onedrive.test.js"; async function main() { let exitCode = 0; @@ -14,6 +16,8 @@ async function main() { { name: "ContextGraph", fn: runContextGraphTests }, { name: "RetrievalPolicy", fn: runRetrievalPolicyTests }, { name: "Feedback", fn: runFeedbackTests }, + { name: "NodeRegistry", fn: runNodeRegistryTests }, + { name: "OneDrive", fn: runOneDriveTests }, ]; for (const suite of suites) {