Compare commits

..
Author SHA1 Message Date
Saket Aryan 8fabc87f6e fix(openclaw): make keyFingerprint actually persist, and serialize flushes
Three findings from @kartik-mem0 on this PR. The first two mean the fingerprint
gate this PR added never worked, and the first would have broken the plugin.

keyFingerprint was not in ALLOWED_KEYS, and assertAllowedKeys throws on an
unknown key. So the first successful lookup wrote a config that every later load
of the plugin rejected outright. Added there and to the manifest schema, with a
round-trip test that parses a config carrying the field.

readPluginAuth builds its result field by field and did not include
keyFingerprint, so the comparison always ran against undefined, the resolved
email was never used again, and telemetry fell back to the API key hash for every
event. That is worse than the defect this PR set out to fix, which at least used
the email. Returned now, with a test against the real reader.

Both were invisible to the telemetry tests because those mock the config module,
and the mock returned a field the real reader drops. That is the actual lesson
here, so the new tests live in config-file.test.ts and config.test.ts against the
real implementations. Confirmed both fail without the fixes.

flush() also had no in-flight guard. Two overlapping flushes each detach the
queue and each prepend their own batch back on failure, so the later batch landed
in front of the earlier one and the truncation then dropped the OLDER events
first, inverting the priority the failure path exists to establish. Serialized
with a guard; a second caller returns and the queue waits for the next flush.
Regression test asserts one delivery in flight and the backlog order preserved.

core 37, openclaw 435, opencode 35, deepseek 47. Wire e2e 9 of 9, exit cost
unchanged at 6s failing and 12ms healthy.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 21:59:03 +05:30
Saket Aryan 7900a5d8d1 docs(plugins): say what the delivery budget actually counts
Review finding. The comments described MAX_DELIVERY_ATTEMPTS as a per-batch
budget mirroring the Python core's. It is not: Python puts the attempt count in
the claim filename so it follows one batch, while this counter lives in the
closure and counts consecutive failed flushes, so events captured during an
outage join the same queue and are dropped with it.

Per-batch accounting would mean an attempt count on every event. The queue is
already bounded, so the simpler rule stands; the comments now describe it rather
than the Python one.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:43:51 +05:30
Saket Aryan cbf97c6077 fix(plugins): bound the exit flush, and stop openclaw deleting a legacy account
Second review round. The first two are regressions from the first round.

The forced exit flush added fifteen seconds to host shutdown. Node re-emits
beforeExit whenever the handler schedules async work, so an unconditional
flush(true) looped until the five-attempt budget was spent, and against the real
3s delivery timeout that is 15s added to the shutdown of whatever editor or CLI
is hosting this. The backoff used to end that loop after one attempt; removing it
for the forced path removed the only thing bounding it. Measured at 15008ms, now
6001ms with a one-shot latch, and 12ms when delivery is healthy, which is the
only case most people ever see.

openclaw deleted a legacy account before it had anything to replace it with. An
install predating keyFingerprint has an email and no fingerprint, so the
comparison failed and clearResolvedAccount() ran immediately; if the re-resolve
then failed because the user was offline the email was gone from disk for good,
and the per-key latch was already set so nothing retried. Now cleared only when a
real fingerprint disagrees, which is the same trade the Python core makes and
documents: verify, and keep what you have until the verification succeeds. The
latch is released on a failed lookup so the next capture tries again.

The overflow test did not exercise the path it is named for: with only two
events captured the re-queue had an empty queue to merge into, so the slice on
the failure path never ran, and that is the half deciding which end gets dropped.
It now fills past the cap before flushing and asserts the exact surviving order.

core 36, openclaw 432, opencode 35, deepseek 47. Wire e2e 9 of 9 and identity
e2e 7 of 7 still pass against a real local server.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:43:25 +05:30
Saket Aryan 44c6a07ddf fix(plugins): flush on exit regardless of backoff, and prefer the backlog over new events
Findings from an independent review of this branch.

The exit-time flush was gated by its own cooldown. beforeExit called the same
flush() that opens with a retryNotBefore check, so after any failed delivery a
process exiting inside the 2s to 60s window sent nothing and the queue died with
it. That is precisely the loss this branch exists to stop, and timer.unref makes
beforeExit often the only remaining chance. flush(force) now skips the cooldown
and beforeExit passes it.

Overflow kept the newest and evicted the batch being retried, which threw away
exactly the events the retry exists to save. Both the failure path and capture()
now keep the backlog and drop the new event instead, matching Python's record(),
which refuses new events once the spool is full.

That change needs a bound, so delivery now gives up after five attempts, as the
Python core does. Without one a payload the server will never accept would be
retried for the whole session and, with the backlog now preferred, would hold the
queue against everything behind it.

Events carry a capture-time timestamp. They now sit through backoff and across
an entire outage, so without one PostHog records them at whatever moment delivery
happened to succeed. It also matters for the uuid dedupe, whose key includes the
event date.

openclaw wiped a resolved account on any capture without an apiKey: the
fingerprint comparison was `undefined === ""`, so a keyless call looked like a key
change. Guarded on a real key being present. Masked today because every call site
supplies one, which is why only a test found it.

Two smaller ones. opencode's PostHog source was "plugin", which named no
particular plugin and matched no vocabulary; it is now OPENCODE_PLUGIN like every
other surface, and a saved insight filtering source = "plugin" needs repointing.
And an em dash in opencode's published description had been rewritten to a —
escape by my own json.dumps when adding the test script; restored.

One openclaw test asserted only not.toThrow() under a name claiming it checked
the identity, and under the fingerprint gate the path it exercised no longer uses
the email at all. It now pins the real condition.

core 35, openclaw 432, opencode 35, pi-agent 89, deepseek 47. The wire e2e still
passes 9 of 9 against a real local server, and the identity e2e 7 of 7.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:30:59 +05:30
Saket Aryan 6f207d2e28 fix(plugins): treat an HTTP error response as a failed delivery
Found by driving the core against a real local server rather than an injected
delivery stub, which is exactly where it could hide: fetch only rejects on a
network-level failure, so a 500, a 503 or a 429 resolved normally and the batch
was counted as delivered and dropped. The unit tests could not catch it because
their stub throws, and real fetch does not.

That is the likelier outage than a refused connection, so the retry added in the
previous commit was covering the rarer half of the problem.

Any non-2xx now throws and takes the retry path. Matching the Python core, which
retries every HTTP error rather than classifying them: the backoff and the queue
bound contain a payload that will never be accepted, because the re-queued batch
sits at the front and is the first thing evicted.

Two tests against the real default delivery path, stubbing fetch rather than the
delivery hook, so a 503 keeps the batch and a 200 clears it.

End to end against a local server, real fetch and real retry timing: events
arrive and carry a uuid, a 503 keeps the batch, an immediate retry is suppressed
by the backoff, the retained event is delivered on recovery with its original
uuid and no duplicate, and a refused connection behaves the same way. Nine of
nine.

Identity checked on the same path: opencode's project_hash is salted, differs per
account, is stable within one, and no raw project id appears in the payload.
openclaw emits two distinct identities across a key change, which is the defect
this branch fixes, observed on the wire rather than through a mock.

agent-plugin-core/ts 32, openclaw 431, opencode 35, pi-agent 89, deepseek 47,
Python core 242 unaffected.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 19:02:51 +05:30
Saket Aryan c05556f3cf fix(plugins): stop the TypeScript telemetry losing events and misattributing accounts
The Python plugin telemetry was hardened across #7322 to #7326. The TypeScript
side has the same defect classes and was not touched, because the two share no
code: agent-plugin-core/python generates into six bundles, agent-plugin-core/
typescript is a separate core each plugin wraps. Fixing one surfaced nothing
about the other, which is how this survived.

Three fixes, all confirmed by running the code rather than reading it.

A failed delivery deleted the batch. The core detached the queue before the
await and swallowed the error, so one blip destroyed the events with nothing
recording that it happened. Probed: two events in, delivery throws, queue goes to
zero, no retry ever. The batch is now put back, bounded by maxQueueSize and
biased to the newest so a long outage costs the oldest events rather than
unbounded memory, and repeated failures back off to a ceiling instead of retrying
every flush against a host that is blocking us. Every event now carries a uuid
stamped at capture, which is what makes the retry safe: PostHog collapses
anything it already accepted.

Deliberately no disk spool, and that is written into the code so it reads as a
decision. Python spools because its hooks are per-tool-call processes that exit
immediately. These plugins live inside a host for a whole session, so
re-queueing covers the same transient failures without the claim and lease
machinery that took three review rounds to get right on the Python side. What it
leaves uncovered is narrow: a session that both starts and ends offline.

openclaw used a cached email forever. The refresh was guarded by !hasEmail, so
after an API key change every event kept reporting under the previous account.
The email is now bound to a fingerprint of the key it was resolved for and only
used while those agree; a mismatch forgets the account and re-resolves. The
resolution latch is per key rather than once per process, so a key changed
mid-session is actually looked up. A row with an email and no fingerprint, which
is what an upgrade from the current version looks like, is verified rather than
adopted, matching the decision reached on #7325.

opencode hashed the project id unsalted, which is reversible for anyone who can
enumerate project ids. Salted with the API key rather than a stored per-install
value: it is already in play, it is high entropy, and it needs no new file and so
no write race to get wrong. Per account rather than per machine, which also keeps
joins working across machines, and it resets on key rotation consistently with
distinctId, which already did.

Also wired opencode's tests into CI. They existed and nothing ran them, so the
regression test asked for on #7322 would not have gated anything.

Verified: agent-plugin-core/ts 30, openclaw 431, opencode 35, pi-agent 89,
deepseek 47. The new tests were each checked against the unfixed code first; the
core ones fail 3 of 3 and the openclaw one fails without the fingerprint gate.

Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
2026-09-17 18:43:27 +05:30
Paurush MittalandGitHub 0df3e4b87d fix(docs): correct rendered titles and remaining SEO links (#7344) 2026-09-16 17:14:33 +05:30
PowderXuandGitHub b51f7692f0 docs(upstash): correct the default collection namespace (#7287) 2026-09-15 19:41:46 +05:30
67 changed files with 725 additions and 178 deletions
@@ -32,6 +32,9 @@ jobs:
- name: Type check - name: Type check
run: bun run type-check run: bun run type-check
- name: Test
run: bun test
- name: Build - name: Build
run: bun run build run: bun run build
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: "Overview" title: "API Reference Overview"
seo: sidebarTitle: "Overview"
title: "API Reference Overview - Mem0"
icon: "terminal" icon: "terminal"
iconType: "solid" iconType: "solid"
description: "REST APIs for memory management, search, and entity operations" description: "REST APIs for memory management, search, and entity operations"
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: 'Delete Memory' title: "Delete Memory API Endpoint"
seo: sidebarTitle: "Delete Memory"
title: "Delete Memory API Endpoint - Mem0"
description: "Delete a single memory by its unique memory ID from the Mem0 platform using the DELETE endpoint." description: "Delete a single memory by its unique memory ID from the Mem0 platform using the DELETE endpoint."
openapi: delete /v1/memories/{memory_id}/ openapi: delete /v1/memories/{memory_id}/
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: 'Update Memory' title: "Update Memory API Endpoint"
seo: sidebarTitle: "Update Memory"
title: "Update Memory API Endpoint - Mem0"
description: "Update the content, metadata, timestamp, or expiration date of a single memory by its unique ID using the PUT endpoint." description: "Update the content, metadata, timestamp, or expiration date of a single memory by its unique ID using the PUT endpoint."
openapi: put /v1/memories/{memory_id}/ openapi: put /v1/memories/{memory_id}/
--- ---
@@ -1,7 +1,6 @@
--- ---
title: 'Add Member' title: "Add Organization Member API Endpoint"
seo: sidebarTitle: "Add Member"
title: "Add Organization Member API Endpoint - Mem0"
description: "Add a new member to an organization with a specified role such as READER or OWNER access level." description: "Add a new member to an organization with a specified role such as READER or OWNER access level."
openapi: post /api/v1/orgs/organizations/{org_id}/members/ openapi: post /api/v1/orgs/organizations/{org_id}/members/
--- ---
@@ -1,7 +1,6 @@
--- ---
title: 'Get Members' title: "Get Organization Members API Endpoint"
seo: sidebarTitle: "Get Members"
title: "Get Organization Members API Endpoint - Mem0"
description: "Retrieve a list of all members belonging to a specific organization on the Mem0 platform." description: "Retrieve a list of all members belonging to a specific organization on the Mem0 platform."
openapi: get /api/v1/orgs/organizations/{org_id}/members/ openapi: get /api/v1/orgs/organizations/{org_id}/members/
--- ---
@@ -1,7 +1,6 @@
--- ---
title: 'Add Member' title: "Add Project Member API Endpoint"
seo: sidebarTitle: "Add Member"
title: "Add Project Member API Endpoint - Mem0"
description: "Add a new member to a project with a specified role such as READER or OWNER access level." description: "Add a new member to a project with a specified role such as READER or OWNER access level."
openapi: post /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/ openapi: post /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/
--- ---
@@ -1,7 +1,6 @@
--- ---
title: 'Get Members' title: "Get Project Members API Endpoint"
seo: sidebarTitle: "Get Members"
title: "Get Project Members API Endpoint - Mem0"
description: "Retrieve a list of all members belonging to a specific project on the Mem0 platform." description: "Retrieve a list of all members belonging to a specific project on the Mem0 platform."
openapi: get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/ openapi: get /api/v1/orgs/organizations/{org_id}/projects/{project_id}/members/
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Configurations title: "Embedder Configuration Reference"
seo: sidebarTitle: "Configurations"
title: "Embedder Configuration Reference - Mem0"
description: "Reference for embedder configuration options in Mem0, including provider selection and model settings." description: "Reference for embedder configuration options in Mem0, including provider selection and model settings."
--- ---
@@ -1,7 +1,6 @@
--- ---
title: AWS Bedrock title: "AWS Bedrock as Embedding Provider"
seo: sidebarTitle: "AWS Bedrock"
title: "AWS Bedrock as Embedding Provider - Mem0"
description: "Configure AWS Bedrock as an embedding provider in Mem0 with IAM credentials and boto3 authentication." description: "Configure AWS Bedrock as an embedding provider in Mem0 with IAM credentials and boto3 authentication."
--- ---
@@ -1,7 +1,6 @@
--- ---
title: Azure OpenAI title: "Azure OpenAI as Embedding Provider"
seo: sidebarTitle: "Azure OpenAI"
title: "Azure OpenAI as Embedding Provider - Mem0"
description: "Configure Azure OpenAI as an embedding provider in Mem0 with API key, deployment, and endpoint settings." description: "Configure Azure OpenAI as an embedding provider in Mem0 with API key, deployment, and endpoint settings."
--- ---
@@ -1,7 +1,6 @@
--- ---
title: Google AI title: "Google AI as Embedding Provider"
seo: sidebarTitle: "Google AI"
title: "Google AI as Embedding Provider - Mem0"
description: "Configure Google AI as an embedding provider in Mem0 using Gemini models and the GOOGLE_API_KEY variable." description: "Configure Google AI as an embedding provider in Mem0 using Gemini models and the GOOGLE_API_KEY variable."
--- ---
@@ -1,7 +1,6 @@
--- ---
title: LangChain title: "LangChain as Embedding Provider"
seo: sidebarTitle: "LangChain"
title: "LangChain as Embedding Provider - Mem0"
description: "Use LangChain as an embedding provider in Mem0 to access a wide range of models through a unified interface." description: "Use LangChain as an embedding provider in Mem0 to access a wide range of models through a unified interface."
--- ---
@@ -1,7 +1,6 @@
--- ---
title: "LM Studio" title: "LM Studio as Embedding Provider"
seo: sidebarTitle: "LM Studio"
title: "LM Studio as Embedding Provider - Mem0"
description: "Configure LM Studio as an embedding provider in Mem0 for local embedding generation with models like nomic-embed-text." description: "Configure LM Studio as an embedding provider in Mem0 for local embedding generation with models like nomic-embed-text."
--- ---
You can use embedding models from LM Studio to run Mem0 locally. You can use embedding models from LM Studio to run Mem0 locally.
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: "Ollama" title: "Ollama as Embedding Provider"
seo: sidebarTitle: "Ollama"
title: "Ollama as Embedding Provider - Mem0"
description: "Configure Ollama as an embedding provider in Mem0 to generate embeddings locally using open-source models." description: "Configure Ollama as an embedding provider in Mem0 to generate embeddings locally using open-source models."
--- ---
You can use embedding models from Ollama to run Mem0 locally. You can use embedding models from Ollama to run Mem0 locally.
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: OpenAI title: "OpenAI as Embedding Provider"
seo: sidebarTitle: "OpenAI"
title: "OpenAI as Embedding Provider - Mem0"
description: "Configure OpenAI as an embedding provider in Mem0 using models like text-embedding-3-large for vector generation." description: "Configure OpenAI as an embedding provider in Mem0 using models like text-embedding-3-large for vector generation."
--- ---
@@ -1,7 +1,6 @@
--- ---
title: Together title: "Together AI as Embedding Provider"
seo: sidebarTitle: "Together"
title: "Together AI as Embedding Provider - Mem0"
description: "Configure Together AI as an embedding provider in Mem0 with support for 1024-dimensional embedding models." description: "Configure Together AI as an embedding provider in Mem0 with support for 1024-dimensional embedding models."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Overview title: "Embedding Providers Overview"
seo: sidebarTitle: Overview
title: "Embedding Providers Overview - Mem0"
description: "Overview of all supported embedding model providers in Mem0, including OpenAI, Azure, Ollama, and more." description: "Overview of all supported embedding model providers in Mem0, including OpenAI, Azure, Ollama, and more."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Configurations title: "LLM Configuration Reference"
seo: sidebarTitle: "Configurations"
title: "LLM Configuration Reference - Mem0"
description: "Reference for LLM configuration options in Mem0 for Python and TypeScript, including value precedence rules." description: "Reference for LLM configuration options in Mem0 for Python and TypeScript, including value precedence rules."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: AWS Bedrock title: "AWS Bedrock as LLM Provider"
seo: sidebarTitle: "AWS Bedrock"
title: "AWS Bedrock as LLM Provider - Mem0"
description: "Configure AWS Bedrock as an LLM provider in Mem0 with IAM authentication and Claude model support." description: "Configure AWS Bedrock as an LLM provider in Mem0 with IAM authentication and Claude model support."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Azure OpenAI title: "Azure OpenAI as LLM Provider"
seo: sidebarTitle: "Azure OpenAI"
title: "Azure OpenAI as LLM Provider - Mem0"
description: "Configure Azure OpenAI as an LLM provider in Mem0 with Azure Identity authentication and deployment settings." description: "Configure Azure OpenAI as an LLM provider in Mem0 with Azure Identity authentication and deployment settings."
--- ---
+1 -1
View File
@@ -3,7 +3,7 @@ title: DeepSeek
description: "Configure DeepSeek as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration." description: "Configure DeepSeek as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
--- ---
To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment variable. You can also optionally set `DEEPSEEK_API_BASE` if you need to use a different API endpoint (defaults to "https://api.deepseek.com"). To use DeepSeek LLM models, you have to set the `DEEPSEEK_API_KEY` environment variable. You can also optionally set `DEEPSEEK_API_BASE` if you need to use a different API endpoint (defaults to `https://api.deepseek.com`).
## Usage ## Usage
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Google AI title: "Google AI as LLM Provider"
seo: sidebarTitle: "Google AI"
title: "Google AI as LLM Provider - Mem0"
description: "Configure Google Gemini as an LLM provider in Mem0 using the google.genai SDK and GOOGLE_API_KEY variable." description: "Configure Google Gemini as an LLM provider in Mem0 using the google.genai SDK and GOOGLE_API_KEY variable."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: LangChain title: "LangChain as LLM Provider"
seo: sidebarTitle: "LangChain"
title: "LangChain as LLM Provider - Mem0"
description: "Use LangChain as an LLM provider in Mem0 to integrate with various chat models through a unified interface." description: "Use LangChain as an LLM provider in Mem0 to integrate with various chat models through a unified interface."
--- ---
+3 -4
View File
@@ -1,7 +1,6 @@
--- ---
title: LM Studio title: "LM Studio as LLM Provider"
seo: sidebarTitle: "LM Studio"
title: "LM Studio as LLM Provider - Mem0"
description: "Configure LM Studio as an LLM provider in Mem0 for running local language models via an OpenAI-compatible API." description: "Configure LM Studio as an LLM provider in Mem0 for running local language models via an OpenAI-compatible API."
--- ---
@@ -78,7 +77,7 @@ m.add(messages, user_id="alice123", metadata={"category": "movies"})
To use LM Studio, you need to: To use LM Studio, you need to:
1. Download and install [LM Studio](https://lmstudio.ai/) 1. Download and install [LM Studio](https://lmstudio.ai/)
2. Start a local server from the "Server" tab 2. Start a local server from the "Server" tab
3. Set the appropriate `lmstudio_base_url` in your configuration (default is usually http://localhost:1234/v1) 3. Set the appropriate `lmstudio_base_url` in your configuration (default is usually `http://localhost:1234/v1`)
</Note> </Note>
## Config ## Config
+1 -1
View File
@@ -3,7 +3,7 @@ title: MiniMax
description: "Configure MiniMax as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration." description: "Configure MiniMax as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
--- ---
To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment variable. You can also optionally set `MINIMAX_API_BASE` if you need to use a different API endpoint (defaults to "https://api.minimax.io/v1"). To use MiniMax LLM models, you have to set the `MINIMAX_API_KEY` environment variable. You can also optionally set `MINIMAX_API_BASE` if you need to use a different API endpoint (defaults to `https://api.minimax.io/v1`).
## Usage ## Usage
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Ollama title: "Ollama as LLM Provider"
seo: sidebarTitle: "Ollama"
title: "Ollama as LLM Provider - Mem0"
description: "Configure Ollama as an LLM provider in Mem0 for running local language models with tool-calling support." description: "Configure Ollama as an LLM provider in Mem0 for running local language models with tool-calling support."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: OpenAI title: "OpenAI as LLM Provider"
seo: sidebarTitle: "OpenAI"
title: "OpenAI as LLM Provider - Mem0"
description: "Configure OpenAI as an LLM provider in Mem0 with support for GPT models and Openrouter compatibility." description: "Configure OpenAI as an LLM provider in Mem0 with support for GPT models and Openrouter compatibility."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Together title: "Together AI as LLM Provider"
seo: sidebarTitle: "Together"
title: "Together AI as LLM Provider - Mem0"
description: "Configure Together AI as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration." description: "Configure Together AI as an LLM provider in Mem0 with API key setup and optional custom endpoint configuration."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: xAI title: "xAI Grok as LLM Provider"
seo: sidebarTitle: "xAI"
title: "xAI Grok as LLM Provider - Mem0"
description: "Configure xAI Grok models as an LLM provider in Mem0 with API key setup and usage examples." description: "Configure xAI Grok models as an LLM provider in Mem0 with API key setup and usage examples."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Overview title: "LLM Providers Overview"
seo: sidebarTitle: Overview
title: "LLM Providers Overview - Mem0"
description: "Overview of all supported LLM providers in Mem0, including OpenAI, Anthropic, Groq, Ollama, and more." description: "Overview of all supported LLM providers in Mem0, including OpenAI, Anthropic, Groq, Ollama, and more."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Overview title: "Reranker Providers Overview"
seo: sidebarTitle: "Overview"
title: "Reranker Providers Overview - Mem0"
description: 'Pick the right reranker path to boost Mem0 search relevance.' description: 'Pick the right reranker path to boost Mem0 search relevance.'
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Configurations title: "Vector Store Configuration Reference"
seo: sidebarTitle: "Configurations"
title: "Vector Store Configuration Reference - Mem0"
description: "Reference for vector database configuration options in Mem0, including provider selection and connection settings." description: "Reference for vector database configuration options in Mem0, including provider selection and connection settings."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: LangChain title: "LangChain as Vector Store Provider"
seo: sidebarTitle: "LangChain"
title: "LangChain as Vector Store Provider - Mem0"
description: "Use LangChain as a unified vector store provider in Mem0 to access multiple vector databases through one interface." description: "Use LangChain as a unified vector store provider in Mem0 to access multiple vector databases through one interface."
--- ---
@@ -102,7 +102,7 @@ Here are the parameters available for configuring Upstash Vector:
| `url` | URL for the Upstash Vector index | `None` | | `url` | URL for the Upstash Vector index | `None` |
| `token` | Token for the Upstash Vector index | `None` | | `token` | Token for the Upstash Vector index | `None` |
| `client` | An `upstash_vector.Index` instance | `None` | | `client` | An `upstash_vector.Index` instance | `None` |
| `collection_name` | The default namespace used | `""` | | `collection_name` | The default namespace used | `"mem0"` |
| `enable_embeddings` | Whether to use Upstash embeddings | `False` | | `enable_embeddings` | Whether to use Upstash embeddings | `False` |
<Note> <Note>
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Overview title: "Vector Store Providers Overview"
seo: sidebarTitle: "Overview"
title: "Vector Store Providers Overview - Mem0"
description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, Oracle, and more." description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, Oracle, and more."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Overview title: "Cookbooks and Tutorials"
seo: sidebarTitle: "Overview"
title: "Cookbooks and Tutorials - Mem0"
description: "Browse cookbook examples and tutorials for building AI applications with Mem0, from companion chatbots to AI agents." description: "Browse cookbook examples and tutorials for building AI applications with Mem0, from companion chatbots to AI agents."
--- ---
@@ -1,7 +1,6 @@
--- ---
title: Delete Memory title: "Delete Memory Operation"
seo: sidebarTitle: "Delete Memory"
title: "Delete Memory Operation - Mem0"
description: Remove memories from Mem0 either individually, in bulk, or via filters. description: Remove memories from Mem0 either individually, in bulk, or via filters.
icon: "trash" icon: "trash"
iconType: "solid" iconType: "solid"
@@ -1,7 +1,6 @@
--- ---
title: Update Memory title: "Update Memory Operation"
seo: sidebarTitle: "Update Memory"
title: "Update Memory Operation - Mem0"
description: Modify an existing memory by updating its content or metadata. description: Modify an existing memory by updating its content or metadata.
icon: "pen-to-square" icon: "pen-to-square"
iconType: "solid" iconType: "solid"
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Overview title: "Integrations Overview"
seo: sidebarTitle: "Overview"
title: "Integrations Overview - Mem0"
description: "Overview of Mem0 integrations with popular AI frameworks and tools for persistent memory and context management." description: "Overview of Mem0 integrations with popular AI frameworks and tools for persistent memory and context management."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: AWS Bedrock title: "AWS Bedrock Integration"
seo: sidebarTitle: "AWS Bedrock"
title: "AWS Bedrock Integration with Mem0"
description: "Use Mem0 with AWS Bedrock and OpenSearch Service for cloud-native persistent semantic memory storage." description: "Use Mem0 with AWS Bedrock and OpenSearch Service for cloud-native persistent semantic memory storage."
--- ---
+1 -1
View File
@@ -3,7 +3,7 @@ title: Codex
description: "Add persistent memory to OpenAI Codex with automatic capture, automatic recall, a search tool, and six memory skills." description: "Add persistent memory to OpenAI Codex with automatic capture, automatic recall, a search tool, and six memory skills."
--- ---
Add persistent memory to [**OpenAI Codex**](https://openai.com/index/codex/) with the Mem0 plugin. Codex forgets everything between tasks. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context on the first prompt of a session. Codex can use the search tool for recall later in the session. Add persistent memory to [**OpenAI Codex**](https://openai.com/codex/) with the Mem0 plugin. Codex forgets everything between tasks. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context on the first prompt of a session. Codex can use the search tool for recall later in the session.
<Info>Current plugin version: `0.3.1`.</Info> <Info>Current plugin version: `0.3.1`.</Info>
+1 -1
View File
@@ -23,7 +23,7 @@ npm install -g flowise
npx flowise start npx flowise start
``` ```
2. Access to the Flowise UI at http://localhost:3000 2. Access to the Flowise UI at `http://localhost:3000`
3. Basic familiarity with [Flowise's LLM orchestration](https://flowiseai.com/#features) concepts 3. Basic familiarity with [Flowise's LLM orchestration](https://flowiseai.com/#features) concepts
## Setup and Configuration ## Setup and Configuration
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
title: Hermes Agent title: Hermes Agent
description: "Add long-term memory to Hermes agents with Mem0 using the managed Platform, a self-hosted server, or fully local OSS." description: "Add long-term memory to Hermes agents with Mem0: managed Platform, a self-hosted server, or fully local OSS — with current-turn recall and background fact extraction."
--- ---
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones for the current question, without slowing down the chat. Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones for the current question, without slowing down the chat.
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: Langchain title: "LangChain Integration"
seo: sidebarTitle: "Langchain"
title: "LangChain Integration with Mem0"
description: "Build personalized AI agents using LangChain for conversation flow and Mem0 for long-term memory retention." description: "Build personalized AI agents using LangChain for conversation flow and Mem0 for long-term memory retention."
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: n8n title: "n8n Integration"
seo: sidebarTitle: "n8n"
title: "n8n Integration with Mem0"
description: "Add long-term memory to n8n workflows and AI Agents with the Mem0 community node, no code required." description: "Add long-term memory to n8n workflows and AI Agents with the Mem0 community node, no code required."
--- ---
@@ -1,7 +1,6 @@
--- ---
title: Custom Instructions title: "Open Source Custom Instructions"
seo: sidebarTitle: "Custom Instructions"
title: "Open Source Custom Instructions - Mem0"
description: Tailor fact extraction so Mem0 stores only the details you care about. description: Tailor fact extraction so Mem0 stores only the details you care about.
icon: "wand-magic-sparkles" icon: "wand-magic-sparkles"
--- ---
@@ -1,7 +1,6 @@
--- ---
title: Multimodal Support title: "Open Source Multimodal Support"
seo: sidebarTitle: "Multimodal Support"
title: "Open Source Multimodal Support - Mem0"
description: Capture and recall memories from both text and images. description: Capture and recall memories from both text and images.
icon: "image" icon: "image"
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: "Overview" title: "Open Source Features Overview"
seo: sidebarTitle: "Overview"
title: "Open Source Features Overview - Mem0"
description: "Self-hosting features that extend Mem0 beyond basic memory storage" description: "Self-hosting features that extend Mem0 beyond basic memory storage"
icon: "list" icon: "list"
--- ---
+1 -1
View File
@@ -56,7 +56,7 @@ The Mem0 REST API server exposes every OSS memory operation over HTTP. Run it al
make bootstrap # starts Compose, creates an admin, issues the first API key make bootstrap # starts Compose, creates an admin, issues the first API key
``` ```
Or to start the stack only and finish setup via the browser wizard at http://localhost:3000: Or to start the stack only and finish setup via the browser wizard at `http://localhost:3000`:
```bash ```bash
cd server cd server
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: "Overview" title: "Open Source Overview"
seo: sidebarTitle: "Overview"
title: "Mem0 Open Source Overview"
description: "Self-host Mem0 with full control over your infrastructure and data" description: "Self-host Mem0 with full control over your infrastructure and data"
icon: "house" icon: "house"
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: CLI title: "CLI for Terminal Memory Management"
seo: sidebarTitle: "CLI"
title: "Mem0 CLI for Terminal Memory Management"
description: "Manage memories from your terminal, for both humans and AI agents." description: "Manage memories from your terminal, for both humans and AI agents."
icon: "terminal" icon: "terminal"
iconType: "solid" iconType: "solid"
@@ -1,7 +1,6 @@
--- ---
title: Custom Instructions title: "Platform Custom Instructions"
seo: sidebarTitle: "Custom Instructions"
title: "Platform Custom Instructions - Mem0"
description: 'Control how Mem0 extracts and stores memories using natural language guidelines' description: 'Control how Mem0 extracts and stores memories using natural language guidelines'
--- ---
@@ -1,7 +1,6 @@
--- ---
title: Multimodal Support title: "Platform Multimodal Support"
seo: sidebarTitle: "Multimodal Support"
title: "Platform Multimodal Support - Mem0"
description: Integrate images and documents into your interactions with Mem0 description: Integrate images and documents into your interactions with Mem0
--- ---
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: "Overview" title: "Platform Overview"
seo: sidebarTitle: "Overview"
title: "Mem0 Platform Overview"
description: "Managed memory layer for AI agents, production-ready in minutes" description: "Managed memory layer for AI agents, production-ready in minutes"
icon: "cloud" icon: "cloud"
--- ---
@@ -1,3 +1,5 @@
import { randomUUID } from "node:crypto";
import { redactSecrets } from "./lifecycle.ts"; import { redactSecrets } from "./lifecycle.ts";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"; const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
@@ -74,34 +76,108 @@ export function errorKind(error: unknown): string {
return error instanceof Error ? error.constructor.name : "other"; return error instanceof Error ? error.constructor.name : "other";
} }
// Delivery is retried in memory, not spooled to disk, and that is a decision
// rather than an omission. The Python core spools because its hooks are separate
// processes that fire per tool call and exit immediately, so nothing survives
// without a file. These plugins are loaded into a host that lives for a whole
// session, so re-queueing covers the same transient failures without the claim
// and lease machinery a correct cross-process spool needs. What that leaves
// uncovered is narrow: a session that both starts and ends with no connectivity.
const RETRY_BACKOFF_CEILING_MS = 60_000;
// Consecutive failed flushes before the queue is dropped. Deliberately NOT the
// same thing as Python's budget, which rides in the claim filename and so
// follows one batch: this counter lives in the closure and counts the outage,
// not the payload. Events captured between attempts join the same queue and go
// with it. Per-batch accounting would need an attempt count on every event, and
// the queue is already bounded, so the simpler rule is the one in force here.
// Without any bound a payload the server will never accept is retried for the
// whole session and, now that the backlog is preferred over new events, holds
// the queue against everything behind it.
const MAX_DELIVERY_ATTEMPTS = 5;
export function createTelemetry(config: TelemetryConfig) { export function createTelemetry(config: TelemetryConfig) {
let queue: Record<string, unknown>[] = []; let queue: Record<string, unknown>[] = [];
let timer: ReturnType<typeof setInterval> | undefined; let timer: ReturnType<typeof setInterval> | undefined;
let consecutiveFailures = 0;
let retryNotBefore = 0;
let exitFlushAttempted = false;
let flushing = false;
const flushThreshold = config.flushThreshold ?? 10; const flushThreshold = config.flushThreshold ?? 10;
const maxQueueSize = config.maxQueueSize ?? 100; const maxQueueSize = config.maxQueueSize ?? 100;
const deliver = config.delivery ?? (async (batch: Record<string, unknown>[]) => { const deliver = config.delivery ?? (async (batch: Record<string, unknown>[]) => {
await fetch(POSTHOG_BATCH_URL, { const response = await fetch(POSTHOG_BATCH_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }), body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
signal: AbortSignal.timeout(3_000), signal: AbortSignal.timeout(3_000),
}); });
// fetch only rejects on a network-level failure. Without this check a 500,
// a 503 or a 429 resolved normally and the batch was counted as delivered
// and dropped, which is the likelier outage than a refused connection.
// Any non-2xx is retried, matching the Python core: the backoff and the
// queue bound contain a payload that will never be accepted, because the
// re-queued batch sits at the front and is the first thing evicted.
if (!response.ok) throw new Error(`posthog responded ${response.status}`);
}); });
async function flush(): Promise<void> { async function flush(force = false): Promise<void> {
// One at a time. Two overlapping flushes each detach the queue and each
// prepend their own batch back on failure, so the later batch lands in front
// of the earlier one and the truncation then drops the OLDER events first,
// inverting the priority the failure path exists to establish. A second
// caller returns immediately; the queue waits for the next flush.
if (flushing) return;
if (!queue.length) return; if (!queue.length) return;
// `force` skips the cooldown. beforeExit is the last chance this process
// gets, and gating it on the same backoff meant that after any failure the
// exit flush did nothing and the queue died with the process, which is the
// loss this whole mechanism exists to prevent.
if (!force && Date.now() < retryNotBefore) return;
const batch = queue; const batch = queue;
queue = []; queue = [];
flushing = true;
try { try {
await deliver(batch); await deliver(batch);
consecutiveFailures = 0;
retryNotBefore = 0;
} catch { } catch {
// Telemetry must never affect plugin behavior. // Put it back. Detaching the batch and swallowing the error deleted the
// events outright, so any blip silently dropped telemetry with nothing
// recording that it had happened. Every event carries a uuid, so a retry
// that duplicates one PostHog already accepted is collapsed there.
//
consecutiveFailures += 1;
if (consecutiveFailures >= MAX_DELIVERY_ATTEMPTS) {
// Give up on the queue so a failing outage cannot hold it for the
// session. This drops whatever is queued now, which includes events
// captured during the outage, not only the batch that kept failing.
consecutiveFailures = 0;
retryNotBefore = 0;
return;
}
// Keep the FRONT on overflow, so the batch being retried survives and a
// new event is what gets dropped. Matches the Python core, where record()
// refuses new events once the spool is full rather than evicting the
// backlog. Keeping the newest would throw away exactly the events this
// retry exists to save.
queue = [...batch, ...queue].slice(0, maxQueueSize);
retryNotBefore = Date.now() + Math.min(2 ** consecutiveFailures * 1_000, RETRY_BACKOFF_CEILING_MS);
} finally {
flushing = false;
} }
} }
function beforeExit(): void { function beforeExit(): void {
void flush(); // Once, and only once. Node re-emits beforeExit whenever the handler
// schedules more async work, so an unconditional forced flush looped until
// the attempt budget was spent: five attempts against a 3s delivery timeout
// is fifteen seconds added to the shutdown of whatever editor or CLI is
// hosting this. The backoff used to end that loop after one attempt, and
// removing it for the forced path removed the only thing bounding it.
if (exitFlushAttempted) return;
exitFlushAttempted = true;
void flush(true);
} }
function build(event: string, properties: Record<string, unknown> = {}): Record<string, unknown> | null { function build(event: string, properties: Record<string, unknown> = {}): Record<string, unknown> | null {
@@ -112,6 +188,15 @@ export function createTelemetry(config: TelemetryConfig) {
return { return {
event: config.eventName?.(event) ?? event, event: config.eventName?.(event) ?? event,
distinct_id: distinctId, distinct_id: distinctId,
// Stamped once, at capture. This is what makes retrying safe: a batch
// re-sent after a failure carries the same ids, so PostHog collapses
// anything it already accepted instead of counting it twice.
uuid: randomUUID(),
// Capture time, not ingestion time. Events now sit through backoff and
// across a whole outage, so without this PostHog records them whenever
// delivery happened to succeed. It also matters for the uuid dedupe
// above, whose key includes the event date.
timestamp: new Date().toISOString(),
properties: { properties: {
...safeProperties(properties), ...safeProperties(properties),
...safeProperties(config.commonProperties ?? {}), ...safeProperties(config.commonProperties ?? {}),
@@ -134,8 +219,10 @@ export function createTelemetry(config: TelemetryConfig) {
try { try {
const payload = build(event, properties); const payload = build(event, properties);
if (!payload) return; if (!payload) return;
// Full means drop this event, not evict the backlog. Same rule as the
// failure path above and as Python's record().
if (queue.length >= maxQueueSize) return;
queue.push(payload); queue.push(payload);
if (queue.length > maxQueueSize) queue = queue.slice(-maxQueueSize);
if (!timer) { if (!timer) {
timer = setInterval(() => void flush(), config.flushIntervalMs ?? 5_000); timer = setInterval(() => void flush(), config.flushIntervalMs ?? 5_000);
timer.unref?.(); timer.unref?.();
@@ -149,6 +236,8 @@ export function createTelemetry(config: TelemetryConfig) {
function resetForTesting(): void { function resetForTesting(): void {
queue = []; queue = [];
consecutiveFailures = 0;
retryNotBefore = 0;
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
timer = undefined; timer = undefined;
process.off("beforeExit", beforeExit); process.off("beforeExit", beforeExit);
@@ -122,3 +122,250 @@ test("error classification does not expose messages", () => {
assert.equal(errorKind(new Error("request timeout")), "timeout"); assert.equal(errorKind(new Error("request timeout")), "timeout");
assert.equal(errorKind(new Error("fetch failed")), "network"); assert.equal(errorKind(new Error("fetch failed")), "network");
}); });
test("a failed delivery keeps the batch instead of deleting it", async () => {
// The defect: the queue was detached before the await and the error swallowed,
// so one blip destroyed the events with nothing recording that it happened.
const attempts: Record<string, unknown>[][] = [];
let failNext = true;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d",
flushThreshold: 1000,
delivery: async (batch) => {
attempts.push(batch);
if (failNext) throw new Error("network down");
},
});
telemetry.capture("one");
telemetry.capture("two");
await telemetry.flush();
assert.equal(attempts.length, 1);
assert.equal(telemetry.queueForTesting().length, 2, "events were dropped on failure");
failNext = false;
// Backoff is in force, so wait it out the way wall time would.
await new Promise((resolve) => setTimeout(resolve, 2_100));
await telemetry.flush();
assert.equal(attempts.length, 2, "never retried");
assert.equal(telemetry.queueForTesting().length, 0);
telemetry.resetForTesting();
});
test("a retried event carries the same uuid so PostHog can collapse it", async () => {
const attempts: Record<string, unknown>[][] = [];
let failNext = true;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d",
flushThreshold: 1000,
delivery: async (batch) => {
attempts.push(batch);
if (failNext) throw new Error("network down");
},
});
telemetry.capture("once");
await telemetry.flush();
failNext = false;
await new Promise((resolve) => setTimeout(resolve, 2_100));
await telemetry.flush();
assert.equal(attempts.length, 2);
const first = attempts[0][0].uuid;
assert.ok(first, "events carry no uuid, so a retry would double count");
assert.equal(attempts[1][0].uuid, first, "retry minted a new uuid");
telemetry.resetForTesting();
});
test("repeated failures back off instead of retrying every flush", async () => {
let calls = 0;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d",
flushThreshold: 1000,
delivery: async () => { calls += 1; throw new Error("blocked"); },
});
telemetry.capture("one");
await telemetry.flush();
await telemetry.flush();
await telemetry.flush();
assert.equal(calls, 1, "a blocked host was hammered on every flush");
assert.equal(telemetry.queueForTesting().length, 1, "the event was lost while backing off");
telemetry.resetForTesting();
});
test("a full queue drops the new event and keeps the batch being retried", async () => {
// Python's record() refuses new events once the spool is full rather than
// evicting the backlog. Keeping the newest here would throw away exactly the
// events the retry exists to save.
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d",
flushThreshold: 1000, maxQueueSize: 3,
delivery: async () => { throw new Error("down"); },
});
// Fill past the cap BEFORE the flush, so the re-queue actually has to truncate.
// Capturing only two left the queue empty at re-queue time and the slice on the
// failure path never ran, which is the half that decides the direction.
telemetry.capture("a");
telemetry.capture("b");
telemetry.capture("c");
await telemetry.flush();
telemetry.capture("d");
telemetry.capture("e");
const events = telemetry.queueForTesting().map((e) => (e as any).event);
assert.equal(events.length, 3, "queue grew past maxQueueSize");
assert.deepEqual(events, ["a", "b", "c"], "the retried batch was evicted instead of the new events");
telemetry.resetForTesting();
});
test("the exit-time flush ignores the backoff", async () => {
// beforeExit is the last chance the process gets. Gating it on the same
// cooldown meant that after any failure it did nothing and the queue died.
let attempts = 0;
let failing = true;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: async () => { attempts += 1; if (failing) throw new Error("down"); },
});
telemetry.capture("a");
await telemetry.flush();
assert.equal(attempts, 1);
failing = false;
await telemetry.flush();
assert.equal(attempts, 1, "the backoff should still hold for an ordinary flush");
await telemetry.flush(true);
assert.equal(attempts, 2, "the exit flush was suppressed by the backoff");
assert.equal(telemetry.queueForTesting().length, 0);
telemetry.resetForTesting();
});
test("every event carries a capture-time timestamp", async () => {
const sent: Record<string, unknown>[][] = [];
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: async (batch) => { sent.push(batch); },
});
telemetry.capture("a");
const capturedAt = Date.now();
await new Promise((resolve) => setTimeout(resolve, 50));
await telemetry.flush();
const stamped = sent[0][0].timestamp as string;
assert.ok(stamped, "no timestamp, so PostHog would record delivery time");
assert.ok(Math.abs(Date.parse(stamped) - capturedAt) < 1_000, "not capture time");
telemetry.resetForTesting();
});
test("a batch the server will never accept is eventually given up on", async () => {
let attempts = 0;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: async () => { attempts += 1; throw new Error("permanently bad"); },
});
telemetry.capture("doomed");
for (let i = 0; i < 8; i += 1) await telemetry.flush(true);
assert.ok(attempts <= 6, `retried ${attempts} times with no cap`);
assert.equal(telemetry.queueForTesting().length, 0, "a doomed batch held the queue forever");
telemetry.resetForTesting();
});
test("an HTTP error response is a failure, not a delivery", async () => {
// fetch only rejects on a network-level failure, so a 500 used to resolve
// normally and the batch was dropped as delivered. Exercises the real default
// delivery path rather than an injected one, which is where this hid.
const realFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = (async () => {
calls += 1;
return new Response("upstream is unwell", { status: 503 });
}) as typeof fetch;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
});
try {
telemetry.capture("during.outage");
await telemetry.flush();
assert.equal(calls, 1, "never reached the network");
assert.equal(telemetry.queueForTesting().length, 1, "a 503 was counted as delivered");
} finally {
globalThis.fetch = realFetch;
telemetry.resetForTesting();
}
});
test("a 2xx is a delivery", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response("ok", { status: 200 })) as typeof fetch;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
});
try {
telemetry.capture("fine");
await telemetry.flush();
assert.equal(telemetry.queueForTesting().length, 0, "a good response did not clear the queue");
} finally {
globalThis.fetch = realFetch;
telemetry.resetForTesting();
}
});
test("the exit flush is attempted once, not until the budget is spent", async () => {
// Node re-emits beforeExit whenever the handler schedules async work, so an
// unconditional forced flush looped until MAX_DELIVERY_ATTEMPTS. Against the
// real 3s delivery timeout that is fifteen seconds added to a host's shutdown.
let attempts = 0;
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: async () => { attempts += 1; throw new Error("down"); },
});
telemetry.capture("a");
const handlers = process.listeners("beforeExit");
const ours = handlers[handlers.length - 1] as () => void;
ours();
ours();
ours();
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(attempts, 1, `exit flush ran ${attempts} times`);
telemetry.resetForTesting();
});
test("overlapping flushes do not reorder the backlog behind newer events", async () => {
// Each flush detaches the queue and prepends its own batch back on failure, so
// two in flight at once put the LATER batch in front of the earlier one. The
// truncation then drops the older events first, inverting the priority the
// failure path exists to establish.
let release: (() => void)[] = [];
const telemetry = createTelemetry({
host: "h", source: "S", version: "1", distinctId: "d", flushThreshold: 1000,
delivery: () => new Promise((_resolve, reject) => { release.push(() => reject(new Error("down"))); }),
});
telemetry.capture("first");
const a = telemetry.flush();
telemetry.capture("second");
const b = telemetry.flush();
release.forEach((fn) => fn());
await Promise.all([a, b]);
const events = telemetry.queueForTesting().map((e) => (e as any).event);
assert.equal(release.length, 1, "a second delivery started while one was in flight");
assert.deepEqual(events, ["first", "second"], `backlog reordered: ${events.join(",")}`);
telemetry.resetForTesting();
});
+20
View File
@@ -35,6 +35,8 @@ export interface PluginAuthConfig {
autoCapture?: boolean; autoCapture?: boolean;
topK?: number; topK?: number;
anonymousTelemetryId?: string; anonymousTelemetryId?: string;
/** SHA-256 prefix of the API key userEmail was resolved for. */
keyFingerprint?: string;
} }
// ============================================================================ // ============================================================================
@@ -135,6 +137,9 @@ export function readPluginAuth(): PluginAuthConfig {
autoCapture: cfg.autoCapture as boolean | undefined, autoCapture: cfg.autoCapture as boolean | undefined,
topK: cfg.topK as number | undefined, topK: cfg.topK as number | undefined,
anonymousTelemetryId: cfg.anonymousTelemetryId as string | undefined, anonymousTelemetryId: cfg.anonymousTelemetryId as string | undefined,
// Without this the reader silently drops it, every fingerprint comparison
// fails against undefined, and the resolved email is never used again.
keyFingerprint: cfg.keyFingerprint as string | undefined,
}; };
} }
@@ -236,6 +241,21 @@ export function getBaseUrl(): string {
return auth.baseUrl || DEFAULT_BASE_URL; return auth.baseUrl || DEFAULT_BASE_URL;
} }
/** Forget the resolved account, so the next capture re-resolves for the current key. */
export function clearResolvedAccount(): void {
const full = readFullConfig() as any;
const cfg = full?.plugins?.entries?.[PLUGIN_ID]?.config;
if (!cfg) return;
let changed = false;
for (const key of ["userEmail", "keyFingerprint"]) {
if (key in cfg) {
delete cfg[key];
changed = true;
}
}
if (changed) writeFullConfig(full);
}
/** Remove anonymousTelemetryId from config (after PostHog aliasing) */ /** Remove anonymousTelemetryId from config (after PostHog aliasing) */
export function clearAnonymousTelemetryId(): void { export function clearAnonymousTelemetryId(): void {
const full = readFullConfig() as any; const full = readFullConfig() as any;
+1
View File
@@ -148,6 +148,7 @@ const ALLOWED_KEYS = [
"mode", "mode",
"apiKey", "apiKey",
"anonymousTelemetryId", "anonymousTelemetryId",
"keyFingerprint",
"baseUrl", "baseUrl",
"userId", "userId",
"userEmail", "userEmail",
@@ -209,6 +209,10 @@
"type": "string", "type": "string",
"description": "Persistent anonymous telemetry identifier" "description": "Persistent anonymous telemetry identifier"
}, },
"keyFingerprint": {
"type": "string",
"description": "Digest of the API key userEmail was resolved for. Set automatically; a mismatch re-resolves the account."
},
"oss": { "oss": {
"type": "object", "type": "object",
"properties": { "properties": {
+53 -11
View File
@@ -1,14 +1,20 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { createTelemetry } from "../agent-plugin-core/typescript/src/telemetry.ts"; import { createTelemetry } from "../agent-plugin-core/typescript/src/telemetry.ts";
import { clearAnonymousTelemetryId, getBaseUrl, readPluginAuth, writePluginAuth } from "./cli/config-file.ts"; import {
clearAnonymousTelemetryId,
clearResolvedAccount,
getBaseUrl,
readPluginAuth,
writePluginAuth,
} from "./cli/config-file.ts";
declare const __OPENCLAW_PLUGIN_VERSION__: string; declare const __OPENCLAW_PLUGIN_VERSION__: string;
export const PLUGIN_VERSION: string = __OPENCLAW_PLUGIN_VERSION__; export const PLUGIN_VERSION: string = __OPENCLAW_PLUGIN_VERSION__;
let cachedAnonymousId: string | undefined; let cachedAnonymousId: string | undefined;
let aliasCheckDone = false; let aliasCheckDone = false;
let emailResolutionAttempted = false; let resolutionAttemptedFor = "";
let currentDistinctId = ""; let currentDistinctId = "";
function enabled(): boolean { function enabled(): boolean {
@@ -33,10 +39,35 @@ function anonymousId(): string {
return (cachedAnonymousId = created); return (cachedAnonymousId = created);
} }
/** SHA-256 prefix of the key an account was resolved for. */
function keyFingerprint(apiKey?: string): string {
return apiKey ? createHash("sha256").update(apiKey).digest("hex").slice(0, 16) : "";
}
function distinctId(apiKey?: string): string { function distinctId(apiKey?: string): string {
try { try {
const email = readPluginAuth().userEmail; const auth = readPluginAuth();
if (email) return createHash("sha256").update(email).digest("hex"); if (auth.userEmail) {
// Only when it belongs to the key in hand. Without this check a cached
// email was used forever: switch to a different account and every event
// kept reporting under the previous one, with nothing to notice it by.
if (auth.keyFingerprint === keyFingerprint(apiKey)) {
return createHash("sha256").update(auth.userEmail).digest("hex");
}
// Only a REAL key that disagrees means the account changed. Without the
// apiKey guard the comparison is `undefined === ""` for any call that
// simply omits the key, so a capture with no context wiped a perfectly
// good account out of openclaw.json.
//
// A row with an email and NO fingerprint is the legacy shape, from an
// install predating this field. Clearing it here deleted a real account
// before anything had replaced it, and if the re-resolve then failed
// because the user was offline the email was gone from disk for good. The
// Python core refuses the same trade: verify, and keep what you have until
// the verification succeeds. resolveEmail below overwrites both fields
// when it does, so there is nothing to clear first.
if (apiKey && auth.keyFingerprint) clearResolvedAccount();
}
} catch { } catch {
// Fall through to the API key or anonymous identity. // Fall through to the API key or anonymous identity.
} }
@@ -66,8 +97,16 @@ function identifyAnonymous(id: string): void {
} }
function resolveEmail(apiKey: string): void { function resolveEmail(apiKey: string): void {
if (emailResolutionAttempted) return; // Latched per key, not once per process. A single boolean meant a key changed
emailResolutionAttempted = true; // mid-session was never looked up, so the fallback identity stuck until restart.
const fingerprint = keyFingerprint(apiKey);
if (resolutionAttemptedFor === fingerprint) return;
resolutionAttemptedFor = fingerprint;
const releaseLatch = () => {
// A failed lookup must not pin the fallback identity for the rest of the
// process. Released so the next capture tries again.
if (resolutionAttemptedFor === fingerprint) resolutionAttemptedFor = "";
};
fetch(`${getBaseUrl().replace(/\/+$/, "")}/v1/ping/`, { fetch(`${getBaseUrl().replace(/\/+$/, "")}/v1/ping/`, {
method: "GET", method: "GET",
headers: { Authorization: `Token ${apiKey}`, "Content-Type": "application/json" }, headers: { Authorization: `Token ${apiKey}`, "Content-Type": "application/json" },
@@ -76,7 +115,7 @@ function resolveEmail(apiKey: string): void {
.then((response) => response.json()) .then((response) => response.json())
.then((data: any) => { .then((data: any) => {
if (!data?.user_email) return; if (!data?.user_email) return;
writePluginAuth({ userEmail: data.user_email }); writePluginAuth({ userEmail: data.user_email, keyFingerprint: fingerprint });
const oldId = createHash("sha256").update(apiKey).digest("hex"); const oldId = createHash("sha256").update(apiKey).digest("hex");
const newId = createHash("sha256").update(data.user_email).digest("hex"); const newId = createHash("sha256").update(data.user_email).digest("hex");
for (const event of telemetry.queueForTesting()) { for (const event of telemetry.queueForTesting()) {
@@ -84,7 +123,8 @@ function resolveEmail(apiKey: string): void {
} }
}) })
.catch(() => { .catch(() => {
// The API-key hash remains a stable fallback. // The API-key hash remains a stable fallback, and the next capture retries.
releaseLatch();
}); });
} }
@@ -96,13 +136,15 @@ export function captureEvent(
if (!enabled()) return; if (!enabled()) return;
try { try {
currentDistinctId = distinctId(context?.apiKey); currentDistinctId = distinctId(context?.apiKey);
let hasEmail = false; let resolvedForThisKey = false;
try { try {
hasEmail = Boolean(readPluginAuth().userEmail); const auth = readPluginAuth();
resolvedForThisKey =
Boolean(auth.userEmail) && auth.keyFingerprint === keyFingerprint(context?.apiKey);
} catch { } catch {
// Resolve it below when possible. // Resolve it below when possible.
} }
if (context?.apiKey && !hasEmail) resolveEmail(context.apiKey); if (context?.apiKey && !resolvedForThisKey) resolveEmail(context.apiKey);
identifyAnonymous(currentDistinctId); identifyAnonymous(currentDistinctId);
telemetry.capture(eventName, { telemetry.capture(eventName, {
mode: context?.mode, mode: context?.mode,
@@ -237,3 +237,43 @@ describe("getBaseUrl", () => {
expect(getBaseUrl()).toBe(DEFAULT_BASE_URL); expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
}); });
}); });
// ---------------------------------------------------------------------------
// keyFingerprint round trip
// ---------------------------------------------------------------------------
describe("keyFingerprint survives a write and read", () => {
it("readPluginAuth returns a persisted keyFingerprint", () => {
// It did not. readPluginAuth builds its result field by field, and this one
// was missing, so every fingerprint comparison ran against undefined, the
// resolved email was never used again, and telemetry silently fell back to
// the API key hash. The telemetry tests could not see it because they mock
// this module and their mock returned the field the real reader dropped.
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
config: {
apiKey: "m0-test",
userEmail: "person@example.com",
keyFingerprint: "0123456789abcdef",
},
},
},
},
});
expect(readPluginAuth().keyFingerprint).toBe("0123456789abcdef");
});
it("writePluginAuth persists it where readPluginAuth looks", () => {
setConfigFile({ plugins: { entries: { "openclaw-mem0": { config: {} } } } });
writePluginAuth({ userEmail: "person@example.com", keyFingerprint: "abc123" });
const written = JSON.parse(mockWriteText.mock.calls.at(-1)![1] as string);
const cfg = written.plugins.entries["openclaw-mem0"].config;
expect(cfg.keyFingerprint).toBe("abc123");
expect(cfg.userEmail).toBe("person@example.com");
});
});
@@ -485,3 +485,18 @@ describe("mem0ConfigSchema.parse() — apiKey edge cases", () => {
expect(cfg.needsSetup).toBe(false); expect(cfg.needsSetup).toBe(false);
}); });
}); });
describe("telemetry fingerprint round trip", () => {
it("a config carrying keyFingerprint is accepted by the real schema", () => {
// What writePluginAuth persists after a successful lookup. It was not in
// ALLOWED_KEYS, and assertAllowedKeys throws, so the first successful
// resolve wrote a config that broke every subsequent load of the plugin.
const persisted = {
apiKey: "m0-test",
userEmail: "person@example.com",
keyFingerprint: "0123456789abcdef",
};
expect(() => mem0ConfigSchema.parse(persisted)).not.toThrow();
});
});
+76 -4
View File
@@ -3,15 +3,30 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock config-file before importing telemetry // Mock config-file before importing telemetry
vi.mock("../cli/config-file.ts", () => ({ vi.mock("../cli/config-file.ts", () => ({
readPluginAuth: vi.fn().mockReturnValue({}), readPluginAuth: vi.fn().mockReturnValue({}),
writePluginAuth: vi.fn(),
clearAnonymousTelemetryId: vi.fn(),
clearResolvedAccount: vi.fn(),
getBaseUrl: vi.fn().mockReturnValue("https://api.mem0.ai"),
})); }));
import { captureEvent } from "../telemetry.ts"; import { captureEvent } from "../telemetry.ts";
import { readPluginAuth } from "../cli/config-file.ts"; import { clearResolvedAccount, readPluginAuth } from "../cli/config-file.ts";
/** sha256(key).slice(0, 16), the shape telemetry.ts stores. */
async function fingerprintOf(apiKey: string): Promise<string> {
const { createHash } = await import("node:crypto");
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
}
describe("telemetry", () => { describe("telemetry", () => {
let fetchSpy: ReturnType<typeof vi.fn>; let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
// Call history has to be cleared per test, not just restored: the mocks are
// module-level vi.fn()s, so without this one test's calls are visible to the
// next and assertions on "was not called" pass or fail by ordering.
vi.clearAllMocks();
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({});
// Reset telemetry enabled state // Reset telemetry enabled state
(globalThis as any).__mem0_telemetry_override = undefined; (globalThis as any).__mem0_telemetry_override = undefined;
fetchSpy = vi.fn().mockResolvedValue({ ok: true }); fetchSpy = vi.fn().mockResolvedValue({ ok: true });
@@ -40,11 +55,31 @@ describe("telemetry", () => {
expect(() => captureEvent("test_event")).not.toThrow(); expect(() => captureEvent("test_event")).not.toThrow();
}); });
it("uses userEmail as distinct ID when available", () => { it("uses userEmail as distinct ID when it belongs to the current key", async () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValueOnce({ // Previously asserted only not.toThrow(), which passed whatever the identity
// turned out to be, and under the fingerprint gate the no-context path does
// not use the email at all. Pin the real condition instead.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "test@example.com", userEmail: "test@example.com",
keyFingerprint: await fingerprintOf("key-a"),
}); });
expect(() => captureEvent("test_event")).not.toThrow();
captureEvent("test_event", {}, { apiKey: "key-a" });
expect(clearResolvedAccount).not.toHaveBeenCalled();
});
it("a capture with no apiKey leaves a resolved account alone", async () => {
// `undefined === ""` made every keyless capture look like a key change, so
// one context-free call wiped a good account out of openclaw.json.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "test@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event");
expect(clearResolvedAccount).not.toHaveBeenCalled();
}); });
it("falls back to a generated anonymous id when no apiKey", () => { it("falls back to a generated anonymous id when no apiKey", () => {
@@ -52,6 +87,43 @@ describe("telemetry", () => {
expect(() => captureEvent("test_event", {}, {})).not.toThrow(); expect(() => captureEvent("test_event", {}, {})).not.toThrow();
}); });
it("keeps using a cached email only while it belongs to the current key", async () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-a" });
expect(clearResolvedAccount).not.toHaveBeenCalled();
});
it("forgets the account when the API key changes", async () => {
// The defect: the cached email was used forever, so events after an account
// switch kept reporting under the previous account.
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-b" });
expect(clearResolvedAccount).toHaveBeenCalled();
});
it("re-resolves for a key it has not looked up before", async () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockReturnValue({
userEmail: "person@example.com",
keyFingerprint: await fingerprintOf("key-a"),
});
captureEvent("test_event", {}, { apiKey: "key-c" });
// The resolution latch is per key, not once per process, so a key changed
// mid-session is actually looked up instead of sticking to the fallback.
expect(fetchSpy).toHaveBeenCalled();
});
it("handles readPluginAuth errors gracefully", () => { it("handles readPluginAuth errors gracefully", () => {
(readPluginAuth as ReturnType<typeof vi.fn>).mockImplementationOnce(() => { (readPluginAuth as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
throw new Error("config read failed"); throw new Error("config read failed");
@@ -38,6 +38,7 @@
"build": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext]", "build": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext]",
"dev": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext] --watch", "dev": "bun build opencode-mem0.ts --outdir dist --target bun --format esm --entry-naming index.[ext] --watch",
"type-check": "tsc --noEmit", "type-check": "tsc --noEmit",
"test": "bun test",
"prepack": "bun run build", "prepack": "bun run build",
"postpack": "" "postpack": ""
}, },
+47 -4
View File
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { buildEvent, captureEvent, isTelemetryEnabled } from "./telemetry"; import { buildEvent, captureEvent, isTelemetryEnabled } from "./telemetry";
@@ -13,7 +15,10 @@ describe("opencode telemetry", () => {
expect(payload).not.toBeNull(); expect(payload).not.toBeNull();
const props = payload!.properties as Record<string, unknown>; const props = payload!.properties as Record<string, unknown>;
expect(payload!.event).toBe("plugin.session_start"); expect(payload!.event).toBe("plugin.session_start");
expect(props.source).toBe("plugin"); // Was "plugin", which named no particular plugin and matched no vocabulary.
// Now shaped like every other surface. Any saved PostHog insight filtering
// source = "plugin" needs repointing; historical data is untouched.
expect(props.source).toBe("OPENCODE_PLUGIN");
expect(props.platform).toBe("opencode"); expect(props.platform).toBe("opencode");
expect(props.memory_count).toBe(5); expect(props.memory_count).toBe(5);
expect(props.$process_person_profile).toBe(false); expect(props.$process_person_profile).toBe(false);
@@ -30,7 +35,7 @@ describe("opencode telemetry", () => {
const props = buildEvent("x", { platform: "HACK", source: "HACK" }, KEY)! const props = buildEvent("x", { platform: "HACK", source: "HACK" }, KEY)!
.properties as Record<string, unknown>; .properties as Record<string, unknown>;
expect(props.platform).toBe("opencode"); expect(props.platform).toBe("opencode");
expect(props.source).toBe("plugin"); expect(props.source).toBe("OPENCODE_PLUGIN");
}); });
test("returns null without an API key (no anonymous events)", () => { test("returns null without an API key (no anonymous events)", () => {
@@ -56,9 +61,12 @@ describe("opencode telemetry", () => {
expect(typeof props.os_version).toBe("string"); expect(typeof props.os_version).toBe("string");
}); });
test("project_hash is sha256(projectId) when a project id is supplied", async () => { test("project_hash is a salted digest of the project id", async () => {
// Previously asserted the bare sha256(projectId), which is the defect: that
// digest is reversible by anyone who can guess a project id. Salted with the
// API key, which is already in play here and is high entropy.
const { createHash } = await import("node:crypto"); const { createHash } = await import("node:crypto");
const expected = createHash("sha256").update("acme-repo").digest("hex"); const expected = createHash("sha256").update(`${KEY}:acme-repo`).digest("hex");
const props = buildEvent("session_start", {}, KEY, "acme-repo")! const props = buildEvent("session_start", {}, KEY, "acme-repo")!
.properties as Record<string, unknown>; .properties as Record<string, unknown>;
expect(props.project_hash).toBe(expected); expect(props.project_hash).toBe(expected);
@@ -76,3 +84,38 @@ describe("opencode telemetry", () => {
} }
}); });
}); });
describe("project_hash salting", () => {
const PROJECT = "my-project";
test("is not a bare digest of the project id", () => {
// The defect: an unsalted SHA-256 over a guessable identifier is reversible
// by anyone who can enumerate project ids.
const unsalted = createHash("sha256").update(PROJECT).digest("hex");
const payload = buildEvent("session_start", {}, KEY, PROJECT) as Record<string, any>;
expect(payload.properties.project_hash).toBeDefined();
expect(payload.properties.project_hash).not.toBe(unsalted);
});
test("differs per account for the same project", () => {
const a = buildEvent("session_start", {}, "m0-account-a", PROJECT) as Record<string, any>;
const b = buildEvent("session_start", {}, "m0-account-b", PROJECT) as Record<string, any>;
expect(a.properties.project_hash).not.toBe(b.properties.project_hash);
});
test("is stable for one account, so joins still work", () => {
const first = buildEvent("session_start", {}, KEY, PROJECT) as Record<string, any>;
const second = buildEvent("session_end", {}, KEY, PROJECT) as Record<string, any>;
expect(first.properties.project_hash).toBe(second.properties.project_hash);
});
test("is omitted rather than unsalted when there is no key", () => {
const payload = buildEvent("session_start", {}, undefined, PROJECT);
// No key means no event at all, so there is no unsalted hash to leak.
expect(payload).toBeNull();
});
});
+22 -5
View File
@@ -25,7 +25,9 @@ const PLUGIN_VERSION = (() => {
let currentDistinctId = ""; let currentDistinctId = "";
const telemetry = createTelemetry({ const telemetry = createTelemetry({
host: "opencode", host: "opencode",
source: "plugin", // Shaped like the platform's EventSource values, as every other surface is.
// "plugin" said nothing about which plugin and matched no vocabulary.
source: "OPENCODE_PLUGIN",
version: PLUGIN_VERSION, version: PLUGIN_VERSION,
distinctId: () => currentDistinctId, distinctId: () => currentDistinctId,
eventName: (event) => `plugin.${event}`, eventName: (event) => `plugin.${event}`,
@@ -40,8 +42,23 @@ function distinctId(apiKey: string): string {
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32); return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
} }
function projectHash(projectId?: string): Record<string, string> { /**
return projectId ? { project_hash: createHash("sha256").update(projectId).digest("hex") } : {}; * Salted so the hash is not enumerable.
*
* An unsalted SHA-256 of a project id is reversible by anyone who can guess the
* id, which for a project identifier is a small space. The API key is the salt:
* it is already in play here (distinctId is a digest of it), it is high entropy,
* and using it needs no per-install file and so no write race to get wrong. The
* hash is therefore per account rather than per machine, which also keeps joins
* working for one user across machines. It resets when the key rotates, which is
* consistent, because distinctId resets with it.
*
* Both are omitted without a key. An event cannot be built without a distinctId
* anyway, so this costs nothing.
*/
function projectHash(projectId?: string, apiKey?: string): Record<string, string> {
if (!projectId || !apiKey) return {};
return { project_hash: createHash("sha256").update(`${apiKey}:${projectId}`).digest("hex") };
} }
export function buildEvent( export function buildEvent(
@@ -51,7 +68,7 @@ export function buildEvent(
projectId?: string, projectId?: string,
): Record<string, unknown> | null { ): Record<string, unknown> | null {
currentDistinctId = apiKey ? distinctId(apiKey) : ""; currentDistinctId = apiKey ? distinctId(apiKey) : "";
const event = telemetry.build(eventType, { ...properties, ...projectHash(projectId) }); const event = telemetry.build(eventType, { ...properties, ...projectHash(projectId, apiKey) });
return event ? { api_key: POSTHOG_API_KEY, ...event } : null; return event ? { api_key: POSTHOG_API_KEY, ...event } : null;
} }
@@ -62,5 +79,5 @@ export function captureEvent(
projectId?: string, projectId?: string,
): void { ): void {
currentDistinctId = apiKey ? distinctId(apiKey) : ""; currentDistinctId = apiKey ? distinctId(apiKey) : "";
telemetry.capture(eventType, { ...properties, ...projectHash(projectId) }); telemetry.capture(eventType, { ...properties, ...projectHash(projectId, apiKey) });
} }