refactor: consolidate agent/editor plugins under integrations/ (#5491)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
interface MemoryLike {
|
||||
id: string;
|
||||
memory?: string;
|
||||
categories?: string[];
|
||||
createdAt?: Date | string;
|
||||
}
|
||||
|
||||
export function formatAge(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const ms = Date.now() - d.getTime();
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function formatMemoryCompact(mem: MemoryLike): string {
|
||||
const cat = mem.categories?.[0] ?? "uncategorized";
|
||||
const age = mem.createdAt ? ` (${formatAge(mem.createdAt)})` : "";
|
||||
return `[${cat}] ${mem.memory ?? "(empty)"}${age} [mem0:${mem.id}]`;
|
||||
}
|
||||
|
||||
export function formatMemoryList(memories: MemoryLike[]): string {
|
||||
if (memories.length === 0) return "No memories found.";
|
||||
return memories
|
||||
.map((m, i) => `${i + 1}. ${formatMemoryCompact(m)}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function groupByCategory(
|
||||
memories: MemoryLike[],
|
||||
): Map<string, MemoryLike[]> {
|
||||
const groups = new Map<string, MemoryLike[]>();
|
||||
for (const m of memories) {
|
||||
const cat = m.categories?.[0] ?? "uncategorized";
|
||||
const list = groups.get(cat) ?? [];
|
||||
list.push(m);
|
||||
groups.set(cat, list);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { detectRunId, resolveSearchFilters, resolveAddParams } from "./scoping.ts";
|
||||
|
||||
const mockExecFileSync = vi.fn();
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFileSync: (...args: any[]) => mockExecFileSync(...args),
|
||||
}));
|
||||
|
||||
const { detectAppId } = await import("./scoping.ts");
|
||||
|
||||
describe("detectAppId", () => {
|
||||
beforeEach(() => {
|
||||
mockExecFileSync.mockReset();
|
||||
});
|
||||
|
||||
it("uses git root basename for a git repo", () => {
|
||||
mockExecFileSync.mockReturnValue("/home/user/projects/my-app\n");
|
||||
expect(detectAppId("/home/user/projects/my-app")).toBe("my-app");
|
||||
});
|
||||
|
||||
it("returns same app_id from any subdirectory in a monorepo", () => {
|
||||
mockExecFileSync.mockReturnValue("/home/user/projects/monorepo\n");
|
||||
const root = detectAppId("/home/user/projects/monorepo");
|
||||
const sub = detectAppId("/home/user/projects/monorepo/packages/core");
|
||||
expect(root).toBe("monorepo");
|
||||
expect(sub).toBe("monorepo");
|
||||
});
|
||||
|
||||
it("falls back to basename when not in a git repo", () => {
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("fatal: not a git repository");
|
||||
});
|
||||
expect(detectAppId("/home/user/scratch")).toBe("scratch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectRunId", () => {
|
||||
it("returns 'unknown' when no session file", () => {
|
||||
expect(detectRunId(undefined)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns a 12-char hex hash for a session file", () => {
|
||||
const id = detectRunId("/tmp/session-abc.json");
|
||||
expect(id).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it("produces different IDs for different session files", () => {
|
||||
const a = detectRunId("/tmp/session-a.json");
|
||||
const b = detectRunId("/tmp/session-b.json");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSearchFilters", () => {
|
||||
const ctx = { userId: "u1", appId: "a1", runId: "r1" };
|
||||
|
||||
it("includes user_id and app_id for project scope", () => {
|
||||
expect(resolveSearchFilters("project", ctx)).toEqual({ user_id: "u1", app_id: "a1" });
|
||||
});
|
||||
|
||||
it("includes run_id for session scope", () => {
|
||||
expect(resolveSearchFilters("session", ctx)).toEqual({ user_id: "u1", app_id: "a1", run_id: "r1" });
|
||||
});
|
||||
|
||||
it("uses wildcard app_id for global scope", () => {
|
||||
expect(resolveSearchFilters("global", ctx)).toEqual({ user_id: "u1", app_id: "*" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAddParams", () => {
|
||||
const ctx = { userId: "u1", appId: "a1", runId: "r1" };
|
||||
|
||||
it("includes userId and appId for project scope", () => {
|
||||
expect(resolveAddParams("project", ctx)).toEqual({ userId: "u1", appId: "a1" });
|
||||
});
|
||||
|
||||
it("includes runId for session scope", () => {
|
||||
expect(resolveAddParams("session", ctx)).toEqual({ userId: "u1", appId: "a1", runId: "r1" });
|
||||
});
|
||||
|
||||
it("only includes userId for global scope", () => {
|
||||
expect(resolveAddParams("global", ctx)).toEqual({ userId: "u1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as path from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { Scope, ScopeContext } from "../types.ts";
|
||||
|
||||
export function detectAppId(cwd: string): string {
|
||||
try {
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
timeout: 3000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return path.basename(root);
|
||||
} catch {
|
||||
return path.basename(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
export function detectRunId(sessionFile: string | undefined): string {
|
||||
if (!sessionFile) return "unknown";
|
||||
return crypto.createHash("sha256").update(sessionFile).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
export function resolveSearchFilters(
|
||||
scope: Scope,
|
||||
ctx: ScopeContext,
|
||||
): Record<string, string> {
|
||||
switch (scope) {
|
||||
case "project":
|
||||
return { user_id: ctx.userId, app_id: ctx.appId };
|
||||
case "session":
|
||||
return { user_id: ctx.userId, app_id: ctx.appId, run_id: ctx.runId };
|
||||
case "global":
|
||||
return { user_id: ctx.userId, app_id: "*" };
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAddParams(
|
||||
scope: Scope,
|
||||
ctx: ScopeContext,
|
||||
): Record<string, string> {
|
||||
switch (scope) {
|
||||
case "project":
|
||||
return { userId: ctx.userId, appId: ctx.appId };
|
||||
case "session":
|
||||
return { userId: ctx.userId, appId: ctx.appId, runId: ctx.runId };
|
||||
case "global":
|
||||
return { userId: ctx.userId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import type MemoryClient from "mem0ai";
|
||||
import type { Scope, ScopeContext, Mem0Config } from "../types.ts";
|
||||
import { DEFAULT_CUSTOM_CATEGORIES } from "../types.ts";
|
||||
import { resolveSearchFilters, resolveAddParams } from "./scoping.ts";
|
||||
import { formatMemoryList } from "./formatting.ts";
|
||||
import { captureToolEvent } from "../telemetry.ts";
|
||||
|
||||
interface MemoryResult {
|
||||
message?: string;
|
||||
eventId?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_LINES = 200;
|
||||
const MAX_OUTPUT_BYTES = 50_000;
|
||||
|
||||
function truncateOutput(text: string): string {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length <= MAX_OUTPUT_LINES && text.length <= MAX_OUTPUT_BYTES) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const kept = lines.slice(0, MAX_OUTPUT_LINES);
|
||||
let result = kept.join("\n");
|
||||
if (result.length > MAX_OUTPUT_BYTES) {
|
||||
result = result.slice(0, MAX_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
const dropped = lines.length - kept.length;
|
||||
if (dropped > 0 || text.length > MAX_OUTPUT_BYTES) {
|
||||
result += `\n\n[Output truncated: showing ${kept.length} of ${lines.length} lines]`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface ToolParams {
|
||||
action: "search" | "add" | "get_all" | "update" | "delete" | "delete_all";
|
||||
query?: string;
|
||||
content?: string;
|
||||
memory_id?: string;
|
||||
scope?: Scope;
|
||||
}
|
||||
|
||||
export function buildToolExecute(
|
||||
mem0: MemoryClient,
|
||||
scopeCtx: ScopeContext,
|
||||
defaultScope: Scope,
|
||||
) {
|
||||
return async (params: ToolParams, signal?: AbortSignal) => {
|
||||
const scope = params.scope ?? defaultScope;
|
||||
|
||||
switch (params.action) {
|
||||
case "search": {
|
||||
if (signal?.aborted) throw new Error("Cancelled");
|
||||
if (!params.query) throw new Error("query is required for search");
|
||||
const filters = resolveSearchFilters(scope, scopeCtx);
|
||||
const result = await mem0.search(params.query, { filters });
|
||||
const memories = result.results ?? [];
|
||||
return {
|
||||
content: [{ type: "text" as const, text: truncateOutput(formatMemoryList(memories)) }],
|
||||
details: { matchCount: memories.length },
|
||||
};
|
||||
}
|
||||
|
||||
case "add": {
|
||||
if (signal?.aborted) throw new Error("Cancelled");
|
||||
if (!params.content) throw new Error("content is required for add");
|
||||
const addParams = resolveAddParams(scope, scopeCtx);
|
||||
const result = await mem0.add(
|
||||
[{ role: "user", content: params.content }],
|
||||
{ ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES },
|
||||
);
|
||||
const res = result as MemoryResult;
|
||||
const msg = res.message ?? "Memory stored.";
|
||||
return {
|
||||
content: [{ type: "text" as const, text: msg }],
|
||||
details: { eventId: res.eventId ?? null, status: res.status ?? null },
|
||||
};
|
||||
}
|
||||
|
||||
case "get_all": {
|
||||
if (signal?.aborted) throw new Error("Cancelled");
|
||||
const filters = resolveSearchFilters(scope, scopeCtx);
|
||||
const result = await mem0.getAll({ filters });
|
||||
const memories = result.results ?? [];
|
||||
return {
|
||||
content: [{ type: "text" as const, text: truncateOutput(formatMemoryList(memories)) }],
|
||||
details: { totalCount: result.count ?? memories.length },
|
||||
};
|
||||
}
|
||||
|
||||
case "update": {
|
||||
if (signal?.aborted) throw new Error("Cancelled");
|
||||
if (!params.memory_id) throw new Error("memory_id is required for update");
|
||||
if (!params.content) throw new Error("content is required for update");
|
||||
const updateResult = await mem0.update(params.memory_id, { text: params.content });
|
||||
const res = updateResult as MemoryResult;
|
||||
return {
|
||||
content: [{ type: "text" as const, text: res.status ?? "Memory updated." }],
|
||||
details: { memoryId: params.memory_id },
|
||||
};
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
if (signal?.aborted) throw new Error("Cancelled");
|
||||
if (!params.memory_id) throw new Error("memory_id is required for delete");
|
||||
const result = await mem0.delete(params.memory_id);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: result.message ?? "Memory deleted." }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
case "delete_all": {
|
||||
if (signal?.aborted) throw new Error("Cancelled");
|
||||
const delParams = resolveAddParams(scope, scopeCtx);
|
||||
const result = await mem0.deleteAll(delParams);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: result.message ?? "All memories deleted." }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMemoryTool(
|
||||
pi: ExtensionAPI,
|
||||
mem0: MemoryClient,
|
||||
config: Mem0Config,
|
||||
getScopeCtx: () => ScopeContext,
|
||||
telemetryCtx?: { apiKey?: string },
|
||||
): void {
|
||||
pi.registerTool({
|
||||
name: "mem0_memory",
|
||||
label: "Mem0 Memory",
|
||||
description:
|
||||
"Search, add, update, and manage persistent semantic memories powered by Mem0. Memories persist across sessions and devices. Output is truncated to 200 lines / 50KB.",
|
||||
promptSnippet: "Semantic memory search and storage via Mem0",
|
||||
promptGuidelines: [
|
||||
'Use mem0_memory with action "search" when the user asks about past conversations, preferences, or decisions',
|
||||
'Use mem0_memory with action "add" to save important facts, preferences, goals, decisions, or lessons the user shares',
|
||||
'Use mem0_memory with action "update" to modify an existing memory — requires memory_id and content. Preserves the memory ID',
|
||||
"Always use the default project scope unless the user EXPLICITLY asks to search across all projects — only then use scope \"global\"",
|
||||
"Do NOT pass scope at all for normal queries — omitting it uses the project default automatically",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
action: StringEnum([
|
||||
"search",
|
||||
"add",
|
||||
"get_all",
|
||||
"update",
|
||||
"delete",
|
||||
"delete_all",
|
||||
] as const),
|
||||
query: Type.Optional(
|
||||
Type.String({ description: "Search query or memory text" }),
|
||||
),
|
||||
content: Type.Optional(
|
||||
Type.String({ description: "Memory content to store or updated text" }),
|
||||
),
|
||||
memory_id: Type.Optional(
|
||||
Type.String({ description: "Memory ID for update or delete" }),
|
||||
),
|
||||
scope: Type.Optional(
|
||||
StringEnum(["project", "session", "global"] as const),
|
||||
),
|
||||
}),
|
||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
||||
const scopeCtx = getScopeCtx();
|
||||
const exec = buildToolExecute(mem0, scopeCtx, config.defaultScope);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await exec(params as ToolParams, signal);
|
||||
const details = (result as any).details ?? {};
|
||||
captureToolEvent((params as ToolParams).action, {
|
||||
success: true,
|
||||
latency_ms: Date.now() - start,
|
||||
result_count: details.matchCount ?? details.totalCount ?? undefined,
|
||||
}, telemetryCtx);
|
||||
return result;
|
||||
} catch (err) {
|
||||
captureToolEvent((params as ToolParams).action, {
|
||||
success: false,
|
||||
latency_ms: Date.now() - start,
|
||||
error_type: err instanceof Error ? err.name : "unknown",
|
||||
}, telemetryCtx);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user