feat(integrations): declare which surface each client is, and its version (#7326)
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applySurfaceHeaders, PLATFORM_APPLICATION, PLATFORM_SOURCE } from "./attribution.ts";
|
||||
|
||||
function client(headers: Record<string, string> = {}) {
|
||||
return { headers: { Authorization: "Token k", ...headers } } as never;
|
||||
}
|
||||
|
||||
describe("applySurfaceHeaders", () => {
|
||||
it("stamps the shared client so every path is attributed, not just commands", () => {
|
||||
const mem0 = client();
|
||||
applySurfaceHeaders(mem0);
|
||||
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
|
||||
|
||||
expect(headers["X-Mem0-Source"]).toBe(PLATFORM_SOURCE);
|
||||
expect(headers["X-Application"]).toBe(PLATFORM_APPLICATION);
|
||||
expect(headers["X-Mem0-Client"]).toMatch(/^mem0-pi-agent\//);
|
||||
expect(headers.Authorization).toBe("Token k");
|
||||
});
|
||||
|
||||
it("defers to a surface an outer wrapper already declared", () => {
|
||||
const mem0 = client({ "X-Mem0-Source": "OPENCLAW", "X-Application": "vscode" });
|
||||
applySurfaceHeaders(mem0);
|
||||
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
|
||||
|
||||
expect(headers["X-Mem0-Source"]).toBe("OPENCLAW");
|
||||
expect(headers["X-Application"]).toBe("vscode");
|
||||
});
|
||||
|
||||
it("appends to the client stack rather than replacing it", () => {
|
||||
const mem0 = client({ "X-Mem0-Client": "openclaw/2.1.0" });
|
||||
applySurfaceHeaders(mem0);
|
||||
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
|
||||
|
||||
expect(headers["X-Mem0-Client"]).toMatch(/^openclaw\/2\.1\.0, mem0-pi-agent\//);
|
||||
});
|
||||
|
||||
it("treats a blank header as absent", () => {
|
||||
const mem0 = client({ "X-Mem0-Source": " " });
|
||||
applySurfaceHeaders(mem0);
|
||||
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
|
||||
|
||||
expect(headers["X-Mem0-Source"]).toBe(PLATFORM_SOURCE);
|
||||
});
|
||||
|
||||
it("bounds the stack so a long chain cannot grow the header without limit", () => {
|
||||
const mem0 = client({ "X-Mem0-Client": "a/1, b/1, c/1, d/1, e/1" });
|
||||
applySurfaceHeaders(mem0);
|
||||
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
|
||||
|
||||
expect(headers["X-Mem0-Client"].split(",").length).toBeLessThanOrEqual(4);
|
||||
expect(headers["X-Mem0-Client"].length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client stack bounding", () => {
|
||||
it("keeps our own entry when the caller already filled the stack", () => {
|
||||
// The defect: pushing then trimming to four dropped exactly the entry this
|
||||
// function exists to add, so we vanished from our own stack.
|
||||
const mem0 = client({ "X-Mem0-Client": "a/1, b/2, c/3, d/4" });
|
||||
applySurfaceHeaders(mem0);
|
||||
const stack = (mem0 as unknown as { headers: Record<string, string> }).headers["X-Mem0-Client"];
|
||||
|
||||
expect(stack).toMatch(/mem0-pi-agent\//);
|
||||
expect(stack.split(",").length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("drops whole entries at the character cap, never a fragment", () => {
|
||||
const long = `${"n".repeat(90)}/1.0, ${"m".repeat(90)}/1.0, ${"o".repeat(90)}/1.0`;
|
||||
const mem0 = client({ "X-Mem0-Client": long });
|
||||
applySurfaceHeaders(mem0);
|
||||
const stack = (mem0 as unknown as { headers: Record<string, string> }).headers["X-Mem0-Client"];
|
||||
|
||||
expect(stack.length).toBeLessThanOrEqual(200);
|
||||
expect(stack.endsWith("/0.0.0") || /mem0-pi-agent\/[\w.\-]+$/.test(stack)).toBe(true);
|
||||
// Every surviving entry is whole: name/version, no severed tail.
|
||||
for (const entry of stack.split(",")) {
|
||||
expect(entry.trim()).toMatch(/^[^/]+\/[^/]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type MemoryClient from "mem0ai";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
/** Surface identity for this plugin, as the platform's EventSource knows it. */
|
||||
export const PLATFORM_SOURCE = "PI_AGENT";
|
||||
|
||||
/** Host app the plugin runs inside. Allowlisted server-side. */
|
||||
export const PLATFORM_APPLICATION = "pi";
|
||||
|
||||
const PLUGIN_VERSION = (() => {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
|
||||
).version as string;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
})();
|
||||
|
||||
const MAX_STACK_ENTRIES = 4;
|
||||
const MAX_STACK_CHARS = 200;
|
||||
|
||||
/**
|
||||
* Append our own entry and bound the result, dropping WHOLE entries.
|
||||
*
|
||||
* Neither cap cuts characters: slicing the joined string severs an identifier
|
||||
* and leaves a fragment the platform parses as a real client name. And the
|
||||
* reserved slot is ours, since it is the only entry this layer can vouch for.
|
||||
*/
|
||||
function boundedStack(callerEntries: string[], own: string): string {
|
||||
const kept: string[] = [];
|
||||
let budget = MAX_STACK_CHARS - own.length;
|
||||
for (const entry of callerEntries.slice(0, MAX_STACK_ENTRIES - 1)) {
|
||||
const cost = entry.length + ", ".length;
|
||||
if (cost > budget) break;
|
||||
budget -= cost;
|
||||
kept.push(entry);
|
||||
}
|
||||
return [...kept, own].join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp surface identity onto the shared client, once, at construction.
|
||||
*
|
||||
* Tagging individual call sites was not enough: automatic recall, capture, the
|
||||
* memory tools and deletion all go through this same client, so everything
|
||||
* except the explicit slash commands reached the platform as generic SDK
|
||||
* traffic. Every request method in the SDK sends `this.headers`, so setting
|
||||
* them here covers all of them.
|
||||
*
|
||||
* X-Mem0-Source and X-Application are set-once, so a wrapper that already named
|
||||
* a surface keeps it. X-Mem0-Client is append-only, so the platform sees the
|
||||
* whole chain rather than only the last speaker.
|
||||
*/
|
||||
export function applySurfaceHeaders(client: MemoryClient): void {
|
||||
const headers = client.headers as Record<string, string>;
|
||||
if (!headers["X-Mem0-Source"]?.trim()) headers["X-Mem0-Source"] = PLATFORM_SOURCE;
|
||||
if (!headers["X-Application"]?.trim()) headers["X-Application"] = PLATFORM_APPLICATION;
|
||||
|
||||
const existing = (headers["X-Mem0-Client"] ?? "")
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
headers["X-Mem0-Client"] = boundedStack(existing, `mem0-pi-agent/${PLUGIN_VERSION}`);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SearchMemoryOptions } from "mem0ai";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import type MemoryClient from "mem0ai";
|
||||
import type { Mem0Config, ScopeContext, Scope } from "./types.ts";
|
||||
@@ -5,6 +6,12 @@ import { DEFAULT_CUSTOM_CATEGORIES } from "./types.ts";
|
||||
import { resolveSearchFilters, resolveAddParams } from "./memory/scoping.ts";
|
||||
import { formatMemoryList, formatMemoryCompact, groupByCategory } from "./memory/formatting.ts";
|
||||
import { captureCommandEvent } from "./telemetry.ts";
|
||||
import { PLATFORM_SOURCE } from "./attribution.ts";
|
||||
|
||||
// Wire identity is set once on the shared client in entry.ts, which covers
|
||||
// every path including recall, capture, tools and deletion. It stays in the
|
||||
// body of the two calls below as well: body `source` is what the backend reads
|
||||
// when the header is absent.
|
||||
|
||||
const SEARCH_TOP_K = 10;
|
||||
|
||||
@@ -29,7 +36,12 @@ export function registerCommands(
|
||||
threshold: config.searchThreshold,
|
||||
topK: SEARCH_TOP_K,
|
||||
rerank: true,
|
||||
});
|
||||
source: PLATFORM_SOURCE,
|
||||
// Widened by exactly this one property. `source` reaches the wire via the
|
||||
// SDK's camelToSnakeKeys spread, but it is absent from SearchMemoryOptions
|
||||
// in the published mem0ai types. A blanket `as never` would also disable
|
||||
// checking of filters, threshold, topK and rerank above.
|
||||
} as SearchMemoryOptions & { source: string });
|
||||
return result.results ?? [];
|
||||
};
|
||||
|
||||
@@ -45,7 +57,7 @@ export function registerCommands(
|
||||
const addParams = resolveAddParams(config.defaultScope, getScopeCtx());
|
||||
const result = await mem0.add(
|
||||
[{ role: "user", content: text }],
|
||||
{ ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES, infer: false },
|
||||
{ ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES, infer: false, source: PLATFORM_SOURCE },
|
||||
);
|
||||
captureCommandEvent("mem0-remember", {}, telemetryCtx);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
import { applySurfaceHeaders } from "./attribution.ts";
|
||||
|
||||
export { buildRecallContext } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
|
||||
|
||||
@@ -29,6 +30,11 @@ export default function mem0Extension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
const mem0 = new MemoryClient({ apiKey: config.apiKey });
|
||||
// Every path below shares this client: automatic recall, capture, the memory
|
||||
// tools and deletion as well as the slash commands. Attribution belongs here
|
||||
// rather than on individual calls, or everything except the commands reports
|
||||
// as generic SDK traffic.
|
||||
applySurfaceHeaders(mem0);
|
||||
|
||||
const scopeCtx: ScopeContext = {
|
||||
userId: resolveUserId(config.userId),
|
||||
|
||||
Reference in New Issue
Block a user