feat(integrations): declare which surface each client is, and its version (#7326)

This commit is contained in:
Saket Aryan
2026-09-18 14:13:11 +05:30
committed by GitHub
parent 4e38b057fd
commit 1a5c7ad28c
31 changed files with 812 additions and 57 deletions
@@ -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}`);
}
+14 -2
View File
@@ -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),