refactor: consolidate agent/editor plugins under integrations/ (#5491)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Kartik
2026-06-12 10:31:35 +05:30
committed by GitHub
co-authored by Claude
parent c676c2c458
commit 2c796d144f
295 changed files with 94 additions and 81 deletions
@@ -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;
}
},
});
}