refactor(integrations): shared agent plugin runtimes and native adapters (#7203)

This commit is contained in:
Kartik
2026-09-08 23:32:25 +05:30
committed by GitHub
parent dae67f74f5
commit 73e7b8763a
369 changed files with 31056 additions and 20464 deletions
@@ -4,37 +4,9 @@ import type { Mem0Config, ScopeContext } from "../types.ts";
import { DEFAULT_CUSTOM_CATEGORIES } from "../types.ts";
import { resolveAddParams } from "../memory/scoping.ts";
import { captureEvent } from "../telemetry.ts";
import { createMemoryLifecycle } from "../../../agent-plugin-core/typescript/src/lifecycle.ts";
interface MessageLike {
role: string;
content?: unknown;
}
function extractText(content: unknown): string | null {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const texts = content
.filter((b: any) => b.type === "text" && typeof b.text === "string")
.map((b: any) => b.text);
return texts.length > 0 ? texts.join("\n") : null;
}
return null;
}
export function extractConversation(
messages: MessageLike[],
): Array<{ role: "user" | "assistant"; content: string }> {
const result: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const msg of messages) {
if (msg.role !== "user" && msg.role !== "assistant") continue;
const text = extractText(msg.content);
if (!text) continue;
result.push({ role: msg.role as "user" | "assistant", content: text });
}
return result;
}
export { extractConversation } from "../../../agent-plugin-core/typescript/src/lifecycle.ts";
export function setupAutoCapture(
pi: ExtensionAPI,
@@ -42,12 +14,13 @@ export function setupAutoCapture(
config: Mem0Config,
getScopeCtx: () => ScopeContext,
telemetryCtx?: { apiKey?: string },
lifecycle: ReturnType<typeof createMemoryLifecycle> = createMemoryLifecycle(),
): void {
if (!config.autoCapture) return;
pi.on("agent_end", async (event) => {
const messages = event.messages ?? [];
const conversation = extractConversation(messages);
const conversation = lifecycle.prepareConversation(messages);
if (conversation.length === 0) return;
const scopeCtx = getScopeCtx();
@@ -6,14 +6,6 @@ vi.mock("./telemetry.ts", () => ({
captureCommandEvent: vi.fn(),
}));
vi.mock("./dream/index.ts", () => ({
acquireDreamLock: vi.fn(() => true),
}));
vi.mock("./dream/prompt.ts", () => ({
DREAM_PROTOCOL: "dream protocol text",
}));
function makeMem0() {
return {
search: vi.fn(),
@@ -56,7 +48,6 @@ const defaultConfig: Mem0Config = {
defaultScope: "project",
contextInjection: false,
searchThreshold: 0.3,
dream: { enabled: false, auto: false, minHours: 24, minSessions: 5, minMemories: 20 },
};
const scopeCtx: ScopeContext = { userId: "test-user", appId: "test-app", runId: "test-run" };
@@ -74,14 +65,14 @@ describe("registerCommands", () => {
it("registers all expected commands", () => {
const names = [...pi._commands.keys()];
expect(names).toContain("mem0-remember");
expect(names).toContain("mem0-forget");
expect(names).toContain("mem0-search");
expect(names).toContain("mem0-tour");
expect(names).toContain("mem0-dream");
expect(names).toContain("mem0-pin");
expect(names).toContain("mem0-scope");
expect(names).toContain("mem0-status");
expect(names).toEqual([
"mem0-remember",
"mem0-forget",
"mem0-search",
"mem0-tour",
"mem0-scope",
"mem0-status",
]);
});
describe("/mem0-forget", () => {
@@ -197,103 +188,6 @@ describe("registerCommands", () => {
});
});
describe("/mem0-pin", () => {
it("uses update to pin in-place, preserving memory ID", async () => {
const ctx = makeCtx(true);
mem0.search.mockResolvedValue({ results: [{ id: "abc-123", memory: "important fact" }] });
mem0.update.mockResolvedValue([]);
await pi._invoke("mem0-pin", "important", ctx);
expect(ctx.ui.confirm).toHaveBeenCalledWith(
"Pin this memory?",
expect.stringContaining("important fact"),
);
expect(mem0.update).toHaveBeenCalledWith("abc-123", { text: "[PINNED] important fact" });
expect(mem0.add).not.toHaveBeenCalled();
expect(mem0.delete).not.toHaveBeenCalled();
});
it("sends a visible confirmation after pinning", async () => {
const ctx = makeCtx(true);
mem0.search.mockResolvedValue({ results: [{ id: "abc-123", memory: "important fact" }] });
mem0.update.mockResolvedValue([]);
await pi._invoke("mem0-pin", "important", ctx);
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: "mem0-pin",
content: expect.stringContaining("Pinned"),
display: true,
}),
);
});
it("does not pin when user cancels", async () => {
const ctx = makeCtx(false);
mem0.search.mockResolvedValue({ results: [{ id: "abc-123", memory: "fact" }] });
await pi._invoke("mem0-pin", "fact", ctx);
expect(mem0.update).not.toHaveBeenCalled();
});
it("skips already-pinned memories with a visible message", async () => {
const ctx = makeCtx();
mem0.search.mockResolvedValue({ results: [{ id: "abc-123", memory: "[PINNED] fact" }] });
await pi._invoke("mem0-pin", "fact", ctx);
expect(ctx.ui.confirm).not.toHaveBeenCalled();
expect(mem0.add).not.toHaveBeenCalled();
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ content: expect.stringContaining("Already pinned"), display: true }),
);
});
it("uses select UI for multiple matches and pins chosen memory", async () => {
const ctx = makeCtx();
mem0.search.mockResolvedValue({
results: [
{ id: "id-1", memory: "fact one" },
{ id: "id-2", memory: "fact two" },
],
});
mem0.update.mockResolvedValue([]);
ctx.ui.select = vi.fn(async (_title: string, options: string[]) => options[1]);
await pi._invoke("mem0-pin", "fact", ctx);
expect(ctx.ui.select).toHaveBeenCalledWith(
expect.stringContaining("which should I pin"),
expect.arrayContaining([
expect.stringContaining("fact one"),
expect.stringContaining("fact two"),
]),
);
expect(mem0.update).toHaveBeenCalledWith("id-2", { text: "[PINNED] fact two" });
});
it("does not pin when user cancels select", async () => {
const ctx = makeCtx();
ctx.ui.select = vi.fn(async () => undefined);
mem0.search.mockResolvedValue({
results: [
{ id: "id-1", memory: "fact one" },
{ id: "id-2", memory: "fact two" },
],
});
await pi._invoke("mem0-pin", "fact", ctx);
expect(mem0.update).not.toHaveBeenCalled();
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ content: expect.stringContaining("Cancelled"), display: true }),
);
});
});
describe("/mem0-search", () => {
it("performs server-side semantic search with a relevance threshold", async () => {
const ctx = makeCtx();
@@ -488,23 +382,4 @@ describe("registerCommands", () => {
});
});
describe("/mem0-dream", () => {
it("feeds the protocol to the agent and shows a clean status line", async () => {
const ctx = makeCtx();
await pi._invoke("mem0-dream", "", ctx);
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ customType: "mem0-dream", display: false }),
expect.objectContaining({ triggerTurn: true }),
);
expect(pi.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
customType: "mem0-dream",
content: expect.stringContaining("Dreaming"),
display: true,
}),
);
});
});
});
@@ -4,9 +4,6 @@ import type { Mem0Config, ScopeContext, Scope } from "./types.ts";
import { DEFAULT_CUSTOM_CATEGORIES } from "./types.ts";
import { resolveSearchFilters, resolveAddParams } from "./memory/scoping.ts";
import { formatMemoryList, formatMemoryCompact, groupByCategory } from "./memory/formatting.ts";
import { DREAM_PROTOCOL } from "./dream/prompt.ts";
import { acquireDreamLock } from "./dream/index.ts";
import { CONFIG_DIR } from "./config/index.ts";
import { captureCommandEvent } from "./telemetry.ts";
const SEARCH_TOP_K = 10;
@@ -184,90 +181,6 @@ export function registerCommands(
},
});
pi.registerCommand("mem0-dream", {
description: "Consolidate memories — merge duplicates, prune stale entries, resolve contradictions",
handler: async (_args, ctx) => {
if (!acquireDreamLock(CONFIG_DIR)) {
ctx.ui.notify("A dream consolidation is already in progress.", "warning");
return;
}
captureCommandEvent("mem0-dream", {}, telemetryCtx);
pi.sendMessage({ customType: "mem0-dream", content: DREAM_PROTOCOL, display: false }, { triggerTurn: true });
sendFeedback(
"mem0-dream",
"**Dreaming** — reviewing your memories to merge duplicates, resolve contradictions, and prune stale entries. I'll report what changed.",
);
},
});
pi.registerCommand("mem0-pin", {
description: "Pin a memory to protect it from dream pruning",
handler: async (args, ctx) => {
const query = args?.trim();
if (!query) {
ctx.ui.notify("Usage: /mem0-pin <query>", "warning");
return;
}
const memories = await searchMemories(query, config.defaultScope);
if (memories.length === 0) {
captureCommandEvent("mem0-pin", { result_count: 0 }, telemetryCtx);
sendFeedback("mem0-pin", `**No matches for "${query}"** — nothing to pin.`);
return;
}
const pinned = (mem: Parameters<typeof formatMemoryCompact>[0]) => {
captureCommandEvent("mem0-pin", { pinned: true }, telemetryCtx);
sendFeedback(
"mem0-pin",
["**Pinned** — protected from dream pruning", `- ${formatMemoryCompact(mem)}`].join("\n"),
);
};
const alreadyPinned = (mem: Parameters<typeof formatMemoryCompact>[0]) => {
sendFeedback("mem0-pin", ["**Already pinned**", `- ${formatMemoryCompact(mem)}`].join("\n"));
};
if (memories.length === 1) {
const target = memories[0];
const text = target.memory ?? "";
if (text.startsWith("[PINNED]")) {
alreadyPinned(target);
return;
}
const confirmed = await ctx.ui.confirm("Pin this memory?", formatMemoryCompact(target));
if (!confirmed) {
sendFeedback("mem0-pin", "**Cancelled** — nothing was pinned.");
return;
}
await mem0.update(target.id, { text: `[PINNED] ${text}` });
pinned(target);
return;
}
const labels = memories.map((m) => formatMemoryCompact(m));
const selected = await ctx.ui.select(
`Found ${pluralize(memories.length, "match", "matches")} for "${query}" — which should I pin?`,
labels,
);
if (!selected) {
sendFeedback("mem0-pin", "**Cancelled** — nothing was pinned.");
return;
}
const idx = labels.indexOf(selected);
if (idx < 0) return;
const target = memories[idx];
const selectedText = target.memory ?? "";
if (selectedText.startsWith("[PINNED]")) {
alreadyPinned(target);
return;
}
await mem0.update(target.id, { text: `[PINNED] ${selectedText}` });
pinned(target);
},
});
pi.registerCommand("mem0-scope", {
description: "Change default memory scope for this session (project, session, global)",
handler: async (args, ctx) => {
@@ -329,7 +242,6 @@ export function registerCommands(
`- Search relevance threshold: ${config.searchThreshold}`,
`- Project memories: ${count}`,
`- Auto-capture: ${config.autoCapture ? "on" : "off"}`,
`- Dream: ${config.dream.enabled ? "enabled" : "disabled"}`,
];
captureCommandEvent("mem0-status", { connected, memory_count: count }, telemetryCtx);
@@ -1,20 +1,12 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { Mem0Config, DreamConfig } from "../types.ts";
import type { Mem0Config } from "../types.ts";
const AGENT_ROOT = path.join(os.homedir(), ".pi", "agent");
export const CONFIG_DIR = AGENT_ROOT;
const CONFIG_PATH = path.join(AGENT_ROOT, "mem0-config.json");
const DEFAULT_DREAM: DreamConfig = {
enabled: true,
auto: true,
minHours: 24,
minSessions: 5,
minMemories: 20,
};
const DEFAULT_CONFIG: Mem0Config = {
apiKey: "",
userId: "",
@@ -22,7 +14,6 @@ const DEFAULT_CONFIG: Mem0Config = {
defaultScope: "project",
contextInjection: true,
searchThreshold: 0.3,
dream: DEFAULT_DREAM,
};
export function loadConfig(): Mem0Config {
@@ -37,15 +28,9 @@ export function loadConfig(): Mem0Config {
}
}
const dream: DreamConfig = {
...DEFAULT_DREAM,
...(fileConfig.dream ?? {}),
};
const config: Mem0Config = {
...DEFAULT_CONFIG,
...fileConfig,
dream,
};
if (process.env.MEM0_API_KEY) {
@@ -1,115 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { DreamState, DreamLock, DreamConfig } from "../types.ts";
const LOCK_STALE_MS = 60 * 60 * 1000;
const DEFAULTS: DreamConfig = {
enabled: true,
auto: true,
minHours: 24,
minSessions: 5,
minMemories: 20,
};
function statePath(stateDir: string): string {
return path.join(stateDir, "mem0-dream-state.json");
}
function lockPath(stateDir: string): string {
return path.join(stateDir, "mem0-dream.lock");
}
function ensureDir(dir: string): void {
try {
fs.mkdirSync(dir, { recursive: true });
} catch { /* exists */ }
}
function readState(stateDir: string): DreamState {
try {
const raw = fs.readFileSync(statePath(stateDir), "utf-8");
return JSON.parse(raw) as DreamState;
} catch {
return { lastConsolidatedAt: 0, sessionsSince: 0, lastSessionId: null };
}
}
function writeState(stateDir: string, state: DreamState): void {
ensureDir(stateDir);
fs.writeFileSync(statePath(stateDir), JSON.stringify(state, null, 2));
}
export function incrementSessionCount(stateDir: string, sessionId: string): void {
const state = readState(stateDir);
if (state.lastSessionId !== sessionId) {
state.sessionsSince++;
state.lastSessionId = sessionId;
writeState(stateDir, state);
}
}
export function checkCheapGates(
stateDir: string,
config: Partial<DreamConfig>,
): { proceed: boolean; reason?: string } {
const minHours = config.minHours ?? DEFAULTS.minHours;
const minSessions = config.minSessions ?? DEFAULTS.minSessions;
const state = readState(stateDir);
const hoursSince = (Date.now() - state.lastConsolidatedAt) / 3_600_000;
if (hoursSince < minHours) {
return { proceed: false, reason: `time: ${hoursSince.toFixed(1)}h < ${minHours}h` };
}
if (state.sessionsSince < minSessions) {
return { proceed: false, reason: `sessions: ${state.sessionsSince} < ${minSessions}` };
}
return { proceed: true };
}
export function checkMemoryGate(
memoryCount: number,
config: Partial<DreamConfig>,
): { pass: boolean; reason?: string } {
const minMemories = config.minMemories ?? DEFAULTS.minMemories;
if (memoryCount < minMemories) {
return { pass: false, reason: `memories: ${memoryCount} < ${minMemories}` };
}
return { pass: true };
}
export function acquireDreamLock(stateDir: string): boolean {
ensureDir(stateDir);
const lp = lockPath(stateDir);
try {
const raw = fs.readFileSync(lp, "utf-8");
const lock = JSON.parse(raw) as DreamLock;
if (Date.now() - lock.startedAt < LOCK_STALE_MS) {
return false;
}
try { fs.unlinkSync(lp); } catch { /* race ok */ }
} catch { /* no lock file */ }
const lock: DreamLock = { pid: process.pid, startedAt: Date.now() };
try {
fs.writeFileSync(lp, JSON.stringify(lock), { flag: "wx" });
return true;
} catch {
return false;
}
}
export function releaseDreamLock(stateDir: string): void {
try { fs.unlinkSync(lockPath(stateDir)); } catch { /* already gone */ }
}
export function recordDreamCompletion(stateDir: string): void {
const state = readState(stateDir);
state.lastConsolidatedAt = Date.now();
state.sessionsSince = 0;
state.lastSessionId = null;
writeState(stateDir, state);
}
@@ -1,22 +0,0 @@
export const DREAM_PROTOCOL = `<mem0-dream>
You are running memory consolidation. Complete these steps using the mem0_memory tool:
1. ORIENT — Call mem0_memory with action "get_all" to list all memories. Count by category. Note oldest/newest.
2. GATHER TARGETS — Review each memory. Classify as:
- DELETE: sensitive information (API keys, passwords, tokens), expired/stale entries, noise, redundant operational details
- MERGE: near-duplicates (same fact stated differently). Keep the better-worded one, delete the other.
- REWRITE: vague, first-person, or poorly-categorized entries. Use mem0_memory "add" with improved text, then "delete" the old one.
- KEEP: everything else.
Skip any memory starting with "[PINNED]".
3. CONSOLIDATE — Execute the changes:
- Delete stale/duplicate entries
- For merges: add the merged text, delete both originals
- For rewrites: add improved version, delete original
4. REPORT — Summarize: how many reviewed, deleted, merged, rewritten, final count.
Quality targets: zero sensitive data stored, zero duplicates, all entries are atomic (one fact each), 15-50 words each.
After consolidation, respond to the user's message normally.
</mem0-dream>`;
+10 -98
View File
@@ -1,24 +1,17 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import MemoryClient from "mem0ai";
import { loadConfig, CONFIG_DIR } from "./config/index.ts";
import { loadConfig } from "./config/index.ts";
import { detectAppId, detectRunId, resolveSearchFilters } from "./memory/scoping.ts";
import { formatMemoryList } from "./memory/formatting.ts";
import { registerMemoryTool } from "./memory/tools.ts";
import { registerCommands } from "./commands.ts";
import { setupAutoCapture } from "./capture/index.ts";
import { MEMORY_POLICY } from "./prompt.ts";
import { DREAM_PROTOCOL } from "./dream/prompt.ts";
import {
incrementSessionCount,
checkCheapGates,
checkMemoryGate,
acquireDreamLock,
releaseDreamLock,
recordDreamCompletion,
} from "./dream/index.ts";
import { captureEvent } from "./telemetry.ts";
import * as os from "node:os";
import type { ScopeContext } from "./types.ts";
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
export { buildRecallContext } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
export function resolveUserId(configUserId: string): string {
if (configUserId) return configUserId;
@@ -27,31 +20,6 @@ export function resolveUserId(configUserId: string): string {
try { return os.userInfo().username; } catch { return "default"; }
}
/**
* Build the auto-recall context block for a turn: search memory with the user's
* prompt and format the top matches so they are guaranteed in context instead of
* relying on the agent to call the tool. Best-effort — returns "" when disabled,
* the prompt is blank, nothing matches, or the search fails; it must never block
* the turn.
*/
export async function buildRecallContext(
prompt: string,
enabled: boolean,
search: (query: string) => Promise<{ results?: unknown[] }>,
): Promise<string> {
if (!enabled) return "";
const q = prompt.trim();
if (!q) return "";
try {
const res = await search(q);
const memories = (res.results ?? []) as Parameters<typeof formatMemoryList>[0];
if (memories.length === 0) return "";
return `<mem0-relevant-memories>\nRetrieved automatically for the current request. This is a shallow first pass — search mem0_memory for more if you need it.\n${formatMemoryList(memories)}\n</mem0-relevant-memories>`;
} catch {
return "";
}
}
export default function mem0Extension(pi: ExtensionAPI): void {
const config = loadConfig();
@@ -73,20 +41,21 @@ export default function mem0Extension(pi: ExtensionAPI): void {
}
const telemetryCtx = { apiKey: config.apiKey };
const lifecycle = createMemoryLifecycle();
// ── Register tool + commands + auto-capture ─────────────────────────
registerMemoryTool(pi, mem0, config, getScopeCtx, telemetryCtx);
registerCommands(pi, mem0, config, getScopeCtx, telemetryCtx);
setupAutoCapture(pi, mem0, config, getScopeCtx, telemetryCtx);
setupAutoCapture(pi, mem0, config, getScopeCtx, telemetryCtx, lifecycle);
captureEvent("pi.plugin.registered", {
auto_capture: config.autoCapture,
dream_enabled: config.dream.enabled,
default_scope: config.defaultScope,
}, telemetryCtx);
// ── session_start: detect project + session, reconstruct scope ──────
pi.on("session_start", async (_event, ctx) => {
lifecycle.beginSession();
scopeCtx.appId = detectAppId(ctx.cwd);
const sessionFile = ctx.sessionManager?.getSessionFile?.();
@@ -96,86 +65,29 @@ export default function mem0Extension(pi: ExtensionAPI): void {
scopeCtx.userId = config.userId;
}
if (config.dream.enabled) {
incrementSessionCount(CONFIG_DIR, scopeCtx.runId);
}
captureEvent("pi.session.start", {}, telemetryCtx);
});
// ── before_agent_start: append memory policy + auto-dream trigger ───
let dreamTriggered = false;
let dreamChecked = false;
// ── before_agent_start: append memory policy and recall ─────────────
pi.on("before_agent_start", async (event, _ctx) => {
let extra = MEMORY_POLICY;
// Guaranteed retrieval: prefetch memories relevant to this prompt so the
// agent always has them, rather than depending on it to call the tool.
const recall = await buildRecallContext(
const recall = await lifecycle.recall(
event.prompt ?? "",
config.contextInjection,
(q) => mem0.search(q, { filters: resolveSearchFilters("project", scopeCtx) }),
);
if (recall) extra += "\n\n" + recall;
if (config.dream.enabled && config.dream.auto && !dreamTriggered && !dreamChecked) {
const gates = checkCheapGates(CONFIG_DIR, config.dream);
if (gates.proceed) {
try {
const filters = resolveSearchFilters("project", scopeCtx);
const result = await mem0.getAll({ filters });
const count = result.count ?? (result.results ?? []).length;
dreamChecked = true;
const memGate = checkMemoryGate(count, config.dream);
if (memGate.pass && acquireDreamLock(CONFIG_DIR)) {
dreamTriggered = true;
extra += "\n\n" + DREAM_PROTOCOL;
captureEvent("pi.dream.triggered", { memory_count: count }, telemetryCtx);
}
} catch {
// Transient error — retry next turn
}
}
}
return {
systemPrompt: (event.systemPrompt ?? "") + "\n\n" + extra,
};
});
// ── agent_end: dream completion check ───────────────────────────────
pi.on("agent_end", async (event) => {
if (!dreamTriggered) return;
const messages = event.messages ?? [];
const hadWriteAction = messages.some((m) => {
if (m.role !== "assistant") return false;
const content = Array.isArray(m.content) ? m.content : [];
return content.some(
(block: any) =>
block.type === "tool_use" &&
block.name === "mem0_memory" &&
["add", "delete", "delete_all"].includes(block.input?.action),
);
});
if (hadWriteAction) {
recordDreamCompletion(CONFIG_DIR);
captureEvent("pi.dream.completed", {}, telemetryCtx);
}
releaseDreamLock(CONFIG_DIR);
dreamTriggered = false;
});
// ── session_shutdown: release dream lock if still held ──────────────
// ── session_shutdown ────────────────────────────────────────────────
pi.on("session_shutdown", async () => {
captureEvent("pi.session.stop", {}, telemetryCtx);
if (dreamTriggered) {
releaseDreamLock(CONFIG_DIR);
dreamTriggered = false;
}
});
}
-11
View File
@@ -1,7 +1,6 @@
export type {
Scope,
Mem0Config,
DreamConfig,
ScopeContext,
CustomCategory,
} from "./types.ts";
@@ -15,16 +14,6 @@ export { formatAge, formatMemoryCompact, formatMemoryList, groupByCategory } fro
export { setupAutoCapture, extractConversation } from "./capture/index.ts";
export {
incrementSessionCount,
checkCheapGates,
checkMemoryGate,
acquireDreamLock,
releaseDreamLock,
recordDreamCompletion,
} from "./dream/index.ts";
export { DREAM_PROTOCOL } from "./dream/prompt.ts";
export { MEMORY_POLICY } from "./prompt.ts";
export { registerCommands } from "./commands.ts";
@@ -1,43 +1,7 @@
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;
}
export {
formatAge,
formatMemoryCompact,
formatMemoryList,
groupByCategory,
} from "../../../agent-plugin-core/typescript/src/formatting.ts";
export type { MemoryLike } from "../../../agent-plugin-core/typescript/src/formatting.ts";
@@ -63,8 +63,8 @@ describe("resolveSearchFilters", () => {
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: "*" });
it("limits global scope to the configured user", () => {
expect(resolveSearchFilters("global", ctx)).toEqual({ user_id: "u1" });
});
});
@@ -2,6 +2,10 @@ 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";
import {
scopeAddParams,
scopeSearchFilters,
} from "../../../agent-plugin-core/typescript/src/scoping.ts";
export function detectAppId(cwd: string): string {
try {
@@ -26,26 +30,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: "*" };
}
return scopeSearchFilters(scope, ctx);
}
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 };
}
return scopeAddParams(scope, ctx);
}
@@ -1,3 +1,4 @@
import { resolveToolScope } from "../../../agent-plugin-core/typescript/src/scoping.ts";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
@@ -17,6 +18,10 @@ interface MemoryResult {
const MAX_OUTPUT_LINES = 200;
const MAX_OUTPUT_BYTES = 50_000;
function normalizeMemoryId(id: string): string {
return id.replace(/^\[?mem0:([0-9a-f-]{36})\]?$/i, "$1");
}
function truncateOutput(text: string): string {
const lines = text.split("\n");
if (lines.length <= MAX_OUTPUT_LINES && text.length <= MAX_OUTPUT_BYTES) {
@@ -50,7 +55,7 @@ export function buildToolExecute(
defaultScope: Scope,
) {
return async (params: ToolParams, signal?: AbortSignal) => {
const scope = params.scope ?? defaultScope;
const scope = resolveToolScope(params.scope, defaultScope);
switch (params.action) {
case "search": {
@@ -96,18 +101,19 @@ export function buildToolExecute(
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 memoryId = normalizeMemoryId(params.memory_id);
const updateResult = await mem0.update(memoryId, { text: params.content });
const res = updateResult as MemoryResult;
return {
content: [{ type: "text" as const, text: res.status ?? "Memory updated." }],
details: { memoryId: params.memory_id },
details: { memoryId },
};
}
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);
const result = await mem0.delete(normalizeMemoryId(params.memory_id));
return {
content: [{ type: "text" as const, text: result.message ?? "Memory deleted." }],
details: {},
@@ -145,7 +151,7 @@ export function registerMemoryTool(
'For multi-part or comparative questions, run several searches with different phrasings and combine the results before answering -- one search is rarely enough',
'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\"",
"Always use the default project scope unless the user EXPLICITLY asks to search across all projects — only after the user selects /mem0-scope global use scope \"global\"",
"Do NOT pass scope at all for normal queries — omitting it uses the project default automatically",
],
parameters: Type.Object({
+56 -197
View File
@@ -1,240 +1,99 @@
/**
* Plugin telemetry — anonymous usage tracking via PostHog.
*
* Sends fire-and-forget events to PostHog using native fetch().
* Events are batched and flushed every 5 seconds or when the queue
* reaches 10 events, whichever comes first.
*
* Disable with: MEM0_TELEMETRY=false
*/
import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { createTelemetry } from "../../agent-plugin-core/typescript/src/telemetry.ts";
import { CONFIG_DIR } from "./config/index.ts";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
const FLUSH_INTERVAL_MS = 5_000;
const FLUSH_THRESHOLD = 10;
let eventQueue: Record<string, unknown>[] = [];
let flushTimer: ReturnType<typeof setInterval> | undefined;
function _loadPluginVersion(): string {
const PLUGIN_VERSION = (() => {
try {
const pkgUrl = new URL("../package.json", import.meta.url);
const pkg = JSON.parse(fs.readFileSync(pkgUrl, "utf-8"));
return pkg.version ?? "unknown";
return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
} catch {
return "unknown";
}
}
const PLUGIN_VERSION = _loadPluginVersion();
// ── Opt-out ──────────────────────────────────────────────────────────────
function isTelemetryEnabled(): boolean {
try {
const val = process.env.MEM0_TELEMETRY;
if (val !== undefined) {
const s = val.toLowerCase();
return s !== "false" && s !== "0" && s !== "no" && s !== "off";
}
return true;
} catch {
return true;
}
}
// ── Identity ─────────────────────────────────────────────────────────────
})();
const TELEMETRY_ID_PATH = path.join(CONFIG_DIR, "mem0-telemetry-id.json");
let _cachedAnonymousId: string | undefined;
let cachedAnonymousId: string | undefined;
let currentDistinctId = "";
let identified = false;
function getOrCreateAnonymousId(): string {
if (_cachedAnonymousId) return _cachedAnonymousId;
function anonymousId(): string {
if (cachedAnonymousId) return cachedAnonymousId;
try {
if (fs.existsSync(TELEMETRY_ID_PATH)) {
const data = JSON.parse(fs.readFileSync(TELEMETRY_ID_PATH, "utf-8"));
if (data.anonymousId) {
_cachedAnonymousId = data.anonymousId;
return _cachedAnonymousId!;
}
}
} catch { /* ignore */ }
const newId = `pi-mem0-anon-${randomUUID().replace(/-/g, "")}`;
const stored = JSON.parse(fs.readFileSync(TELEMETRY_ID_PATH, "utf-8")).anonymousId;
if (typeof stored === "string" && stored) return (cachedAnonymousId = stored);
} catch {
// First run or unreadable identity file.
}
const created = `pi-mem0-anon-${randomUUID().replace(/-/g, "")}`;
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(TELEMETRY_ID_PATH, JSON.stringify({ anonymousId: newId }), "utf-8");
} catch { /* ignore */ }
_cachedAnonymousId = newId;
return newId;
}
function getDistinctId(apiKey?: string): string {
if (apiKey) {
return createHash("sha256").update(apiKey).digest("hex");
}
return getOrCreateAnonymousId();
}
let _identifyDone = false;
function maybeBuildIdentifyEvent(distinctId: string): Record<string, unknown> | null {
if (_identifyDone) return null;
if (!distinctId || distinctId.startsWith("pi-mem0-anon-")) return null;
try {
if (!fs.existsSync(TELEMETRY_ID_PATH)) {
_identifyDone = true;
return null;
}
const data = JSON.parse(fs.readFileSync(TELEMETRY_ID_PATH, "utf-8"));
const storedAnon = data.anonymousId;
if (!storedAnon) {
_identifyDone = true;
return null;
}
const identifyEvent = {
event: "$identify",
distinct_id: distinctId,
properties: { $anon_distinct_id: storedAnon, $lib: "posthog-node" },
};
try {
fs.unlinkSync(TELEMETRY_ID_PATH);
} catch { /* ignore */ }
_identifyDone = true;
_cachedAnonymousId = undefined;
return identifyEvent;
fs.writeFileSync(TELEMETRY_ID_PATH, JSON.stringify({ anonymousId: created }), "utf-8");
} catch {
return null;
// An unwritable config directory must not break the plugin.
}
return (cachedAnonymousId = created);
}
function distinctId(apiKey?: string): string {
return apiKey ? createHash("sha256").update(apiKey).digest("hex") : anonymousId();
}
function previousAnonymousId(id: string): string | undefined {
if (identified || id.startsWith("pi-mem0-anon-")) return undefined;
identified = true;
try {
const stored = JSON.parse(fs.readFileSync(TELEMETRY_ID_PATH, "utf-8")).anonymousId;
fs.unlinkSync(TELEMETRY_ID_PATH);
cachedAnonymousId = undefined;
return typeof stored === "string" && stored ? stored : undefined;
} catch {
return undefined;
}
}
// ── Flush machinery ──────────────────────────────────────────────────────
function ensureFlushTimer(): void {
if (flushTimer) return;
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
if (typeof flushTimer === "object" && "unref" in flushTimer) {
flushTimer.unref();
}
}
let _exitHandlerInstalled = false;
function ensureExitHandler(): void {
if (_exitHandlerInstalled) return;
_exitHandlerInstalled = true;
process.on("beforeExit", async () => {
if (eventQueue.length === 0) return;
const batch = eventQueue;
eventQueue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
try {
await fetch(POSTHOG_HOST, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
},
body,
signal: AbortSignal.timeout(3_000),
});
} catch { /* silently swallow */ }
});
}
function flushEvents(): void {
if (eventQueue.length === 0) return;
const batch = eventQueue;
eventQueue = [];
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
fetch(POSTHOG_HOST, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(Buffer.byteLength(body)),
},
body,
signal: AbortSignal.timeout(3_000),
}).catch(() => { /* silently swallow */ });
}
// ── Public API ───────────────────────────────────────────────────────────
const telemetry = createTelemetry({
host: "pi",
source: "PI_AGENT_PLUGIN",
version: PLUGIN_VERSION,
distinctId: () => currentDistinctId,
});
export function captureEvent(
eventName: string,
properties: Record<string, unknown> = {},
ctx?: { apiKey?: string },
context?: { apiKey?: string },
): void {
if (!isTelemetryEnabled()) return;
try {
const distinctId = getDistinctId(ctx?.apiKey);
const identifyEvent = maybeBuildIdentifyEvent(distinctId);
if (identifyEvent) {
eventQueue.push(identifyEvent);
}
eventQueue.push({
event: eventName,
distinct_id: distinctId,
properties: {
source: "PI_AGENT_PLUGIN",
language: "node",
plugin_version: PLUGIN_VERSION,
node_version: process.version,
os: process.platform,
$process_person_profile: false,
$lib: "posthog-node",
...properties,
},
});
ensureFlushTimer();
ensureExitHandler();
if (eventQueue.length >= FLUSH_THRESHOLD) {
flushEvents();
}
} catch { /* silently swallow */ }
currentDistinctId = distinctId(context?.apiKey);
const anonymous = previousAnonymousId(currentDistinctId);
if (anonymous) telemetry.capture("$identify", { $anon_distinct_id: anonymous });
telemetry.capture(eventName, properties);
}
export function captureToolEvent(
action: string,
properties: Record<string, unknown> = {},
ctx?: { apiKey?: string },
context?: { apiKey?: string },
): void {
captureEvent("pi.tool.mem0_memory", { action, ...properties }, ctx);
captureEvent("pi.tool.mem0_memory", { action, ...properties }, context);
}
export function captureCommandEvent(
command: string,
properties: Record<string, unknown> = {},
ctx?: { apiKey?: string },
context?: { apiKey?: string },
): void {
captureEvent(`pi.command.${command}`, properties, ctx);
captureEvent(`pi.command.${command}`, properties, context);
}
// ── Test helpers ─────────────────────────────────────────────────────────
export function _getEventQueue(): Record<string, unknown>[] {
return eventQueue;
return telemetry.queueForTesting();
}
export function _resetForTesting(): void {
eventQueue = [];
if (flushTimer) {
clearInterval(flushTimer);
flushTimer = undefined;
}
_cachedAnonymousId = undefined;
_identifyDone = false;
telemetry.resetForTesting();
cachedAnonymousId = undefined;
currentDistinctId = "";
identified = false;
}
-20
View File
@@ -1,13 +1,5 @@
export type Scope = "project" | "session" | "global";
export interface DreamConfig {
enabled: boolean;
auto: boolean;
minHours: number;
minSessions: number;
minMemories: number;
}
export interface Mem0Config {
apiKey: string;
userId: string;
@@ -15,18 +7,6 @@ export interface Mem0Config {
defaultScope: Scope;
contextInjection: boolean;
searchThreshold: number;
dream: DreamConfig;
}
export interface DreamState {
lastConsolidatedAt: number;
sessionsSince: number;
lastSessionId: string | null;
}
export interface DreamLock {
pid: number;
startedAt: number;
}
export interface ScopeContext {