refactor(integrations): shared agent plugin runtimes and native adapters (#7203)
This commit is contained in:
@@ -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>`;
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user