fix(plugins): stamp surface identity at record time, not send time
Six defects in 0.3.x plugin telemetry. Defects 1, 2 and 6 were not three bugs: they were one spool protocol getting three properties wrong. Identity was decided by the wrong process. `harness` was stamped in record(), correctly, but `source` was read from a module global in flush() — so whichever process drained the spool named every event in it. Two processes never call init(): mcp_server.py, and the detached `python3 telemetry.py` sender that spawn_flush() starts. record() now stamps source beside harness, and the build generates core/_harness_id.py per host so identity resolves with no init() call at all. That also unifies two defaults that disagreed (`<host>_plugin` vs `MEM0_<HOST>_PLUGIN`), which could yield three source values for one plugin. Ownership was inferred, not held. Path.replace is os.rename, which preserves mtime, so a claim made after a quiet minute inherited the spool's age and was stealable the instant it existed. Claims are touched on creation and the per-batch rewrite doubles as a lease heartbeat. Progress was not durable. flush() returned on the first failed batch without truncating, so the retry re-posted from index 0 — 150 events delivered 250 times. It now rewrites the claim with the unsent remainder after every batch, bounding a crash to one repeated batch, and each event carries a uuid. Parked batches starved. They were only reachable when no spool existed, and because sessions keep recording there usually was one, so a batch parked by a failed send waited until the 7-day expiry deleted it unsent — despite its own presence being what starts the sender. flush() drains them in the same run, and expiry now applies only after a genuine retry has failed. code.install counted upgrades and repeat sessions. is_first_run() read the identity file, which only a successful flush writes, so an offline user recorded an install every session forever. A dedicated install-state.json is claimed atomically at record time; a non-empty data directory reads as an upgrade. The docs called this anonymous. Every event carries the account email, and the hashes were unsalted SHA-256 over a git remote URL or an absolute path containing the username. READMEs, the module docstring and a new docs section now say what the code does, and repo/session digests are salted per install. A cached email outlived an API key change. It is now re-resolved when the key's fingerprint differs, and $identify aliases anonymous->email only — aliasing one account to another merges person profiles irreversibly. All six shipped green because the shared core's only tests lived under one host, behind a conftest that calls init() at import. Core behaviour was never exercised uninitialised. Adds agent-plugin-core/tests with no init, including subprocess tests and coverage for the portable plugin, which has no flush worker and would pass a native-only test vacuously. Also puts the three surface headers on the SDKs, CLIs and integrations, and corrects a README claiming ZAPIER/STRANDS were already in the platform allowlist. Verified: 59 core tests, 203 claude-code, 11 cursor, 5 codex, 2 kimi, 6 antigravity. ruff and compileall clean. --check clean for all six hosts. TypeScript changes are not typechecked locally (deps not installed). Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
This commit is contained in:
@@ -95,6 +95,38 @@ interface ClientIdentity {
|
||||
const IDENTITY_CACHE_MAX_DEFAULT = 50;
|
||||
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
|
||||
|
||||
const SDK_VERSION = "3.1.8";
|
||||
|
||||
/**
|
||||
* Surface-identity headers.
|
||||
*
|
||||
* X-Mem0-Source and X-Application are SET-ONCE by contract: whichever layer is
|
||||
* outermost sets them and nothing below overwrites, so a plugin wrapping this
|
||||
* SDK keeps its own identity. X-Mem0-Client is APPEND-ONLY - every layer adds
|
||||
* itself, so the platform sees the whole stack and not just the last speaker.
|
||||
*/
|
||||
function surfaceHeaders(): Record<string, string> {
|
||||
const env: Record<string, string | undefined> =
|
||||
typeof process !== "undefined" && process.env ? process.env : {};
|
||||
const existing = (env.MEM0_CLIENT_STACK ?? "").trim();
|
||||
const entries = existing
|
||||
? existing
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
entries.push(`mem0-js/${SDK_VERSION}`);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"X-Mem0-Client": entries.slice(0, 4).join(", ").slice(0, 200),
|
||||
};
|
||||
const source = (env.MEM0_SOURCE ?? "").trim();
|
||||
if (source) headers["X-Mem0-Source"] = source;
|
||||
const application = (env.MEM0_APPLICATION ?? "").trim();
|
||||
if (application) headers["X-Application"] = application;
|
||||
return headers;
|
||||
}
|
||||
|
||||
export default class MemoryClient {
|
||||
apiKey: string;
|
||||
host: string;
|
||||
@@ -129,6 +161,7 @@ export default class MemoryClient {
|
||||
this.headers = {
|
||||
Authorization: `Token ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...surfaceHeaders(),
|
||||
};
|
||||
|
||||
this.client = axios.create({
|
||||
|
||||
Reference in New Issue
Block a user