diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20842238..80600ae5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,15 @@ name: CI - Type Check, Format & Lint on: pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: quality-checks: name: Quality Checks runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/claude-auto-fix-ci.yml b/.github/workflows/claude-auto-fix-ci.yml index 993545c6..246e94c1 100644 --- a/.github/workflows/claude-auto-fix-ci.yml +++ b/.github/workflows/claude-auto-fix-ci.yml @@ -19,6 +19,7 @@ jobs: github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.pull_requests[0] runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v5 @@ -41,18 +42,22 @@ jobs: - name: Get CI failure details id: failure_details uses: actions/github-script@v7 + env: + RUN_ID: ${{ github.event.workflow_run.id }} with: script: | + const runId = Number(process.env.RUN_ID); + const run = await github.rest.actions.getWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }} + run_id: runId }); const jobs = await github.rest.actions.listJobsForWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, - run_id: ${{ github.event.workflow_run.id }} + run_id: runId }); const failedJobs = jobs.data.jobs.filter(job => job.conclusion === 'failure'); diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 9da46700..38dbdf9d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -4,14 +4,23 @@ on: pull_request: types: [opened, synchronize, ready_for_review, reopened] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: claude-review: + # Fork PRs run with a read-only token and no secrets/OIDC id-token, so the + # Claude action can never authenticate there and the job always fails. Skip + # it for forks so those PRs report a clean skipped check instead of a red X. if: | github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'graphite-app[bot]' && github.actor != 'dependabot[bot]' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write pull-requests: write diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 17a26bc7..c37d662d 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -13,11 +13,36 @@ on: jobs: claude: if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ( + github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + (github.event.review.author_association == 'OWNER' || + github.event.review.author_association == 'MEMBER' || + github.event.review.author_association == 'COLLABORATOR') + ) || + ( + github.event_name == 'issues' && + (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && + (github.event.issue.author_association == 'OWNER' || + github.event.issue.author_association == 'MEMBER' || + github.event.issue.author_association == 'COLLABORATOR') + ) runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read pull-requests: read diff --git a/.github/workflows/publish-agent-framework-python.yml b/.github/workflows/publish-agent-framework-python.yml index bb46d3d3..ef74637d 100644 --- a/.github/workflows/publish-agent-framework-python.yml +++ b/.github/workflows/publish-agent-framework-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/agent-framework-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-ai-sdk.yml b/.github/workflows/publish-ai-sdk.yml index 27817b20..1ac60cf1 100644 --- a/.github/workflows/publish-ai-sdk.yml +++ b/.github/workflows/publish-ai-sdk.yml @@ -7,9 +7,14 @@ on: paths: - "packages/ai-sdk/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-cartesia-sdk-python.yml b/.github/workflows/publish-cartesia-sdk-python.yml index 14155ac1..aa65eb4f 100644 --- a/.github/workflows/publish-cartesia-sdk-python.yml +++ b/.github/workflows/publish-cartesia-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/cartesia-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-memory-graph.yml b/.github/workflows/publish-memory-graph.yml index 66dcbf3c..6dff18ea 100644 --- a/.github/workflows/publish-memory-graph.yml +++ b/.github/workflows/publish-memory-graph.yml @@ -7,9 +7,14 @@ on: paths: - "packages/memory-graph/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-openai-sdk-python.yml b/.github/workflows/publish-openai-sdk-python.yml index 141477a8..e6066bcf 100644 --- a/.github/workflows/publish-openai-sdk-python.yml +++ b/.github/workflows/publish-openai-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/openai-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-pipecat-sdk-python.yml b/.github/workflows/publish-pipecat-sdk-python.yml index a0152ce3..f98ab1c8 100644 --- a/.github/workflows/publish-pipecat-sdk-python.yml +++ b/.github/workflows/publish-pipecat-sdk-python.yml @@ -7,9 +7,14 @@ on: paths: - "packages/pipecat-sdk-python/pyproject.toml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/.github/workflows/publish-tools.yml b/.github/workflows/publish-tools.yml index 46a56329..8dfb8569 100644 --- a/.github/workflows/publish-tools.yml +++ b/.github/workflows/publish-tools.yml @@ -7,9 +7,14 @@ on: paths: - "packages/tools/package.json" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read id-token: write diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e7c29ec..feb1ed41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,16 +36,7 @@ Before you begin, ensure you have the following installed: # You'll need to add your API keys and database URLs ``` -4. **Change proxy for local development** - - Add this in your `proxy.ts`(apps/web) before retrieving the cookie (`getSessionCookie(request)`): - - ```ts - if (url.hostname === "localhost") { - return NextResponse.next(); - } - -5. **Start the Development Server** +4. **Start the Development Server** ```bash bun run dev:local diff --git a/README.md b/README.md index a134afad..33a98c5f 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,13 @@

- State-of-the-art memory and context engine for AI. + State-of-the-art memory and context engine for AI. And yes - you can use it as a company/personal brain.

Docs · Quickstart · + Self-host · Dashboard · Discord

@@ -23,6 +24,10 @@ docs

+

+ English · 简体中文 +

+ --- Supermemory is the memory and context layer for AI. **#1 on [LongMemEval](https://github.com/xiaowu0162/LongMemEval), [LoCoMo](https://github.com/snap-research/locomo), and [ConvoMem](https://github.com/Salesforce/ConvoMem)** — the three major benchmarks for AI memory. @@ -73,6 +78,21 @@ No vector DB config. No embedding pipelines. No chunking strategies. **[→ Jump to developer quickstart](#build-with-supermemory-api)** + + + + + +

🖥️ I want to run it myself

+ +State-of-the-art memory, on your machine. **One binary. Zero config.** Bring any model — or run fully offline with Ollama. + +```bash +curl -fsSL https://supermemory.ai/install | bash +``` + +**[→ Jump to Supermemory local](#supermemory-local--run-it-yourself)** + @@ -297,6 +317,39 @@ Full API reference → [supermemory.ai/docs](https://supermemory.ai/docs) --- +## Supermemory local — run it yourself + +State-of-the-art memory, on your machine. One binary. Zero config. + +```bash +curl -fsSL https://supermemory.ai/install | bash +# or +npx supermemory local +``` + +```bash +supermemory-server +``` + +First boot sets up the embedded Supermemory graph engine, local embeddings, and your credentials, then prints an API key. The full Memory API — documents, memories, user profiles, hybrid search — runs against `http://localhost:6767`. + +```typescript +const client = new Supermemory({ + apiKey: "sm_...", + baseURL: "http://localhost:6767", // that's the only change +}); +``` + +- **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot. +- **Embeddings** — local `Xenova/bge-base-en-v1.5` by default (no API key); optionally OpenAI, Gemini, or Ollama. Same provider stack as cloud. +- **Fully offline if you want** — point it at Ollama (`gpt-oss:20b` works great) and nothing leaves your machine. +- **Your data, one directory** — everything lives in `./.supermemory`, easy to back up or move. +- **Same API as the platform** — prototype locally, ship on the hosted platform by changing `baseURL`. + +Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, [configuration](https://supermemory.ai/docs/self-hosting/configuration), [embeddings](https://supermemory.ai/docs/self-hosting/embeddings), and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise). + +--- + ## Benchmarks Supermemory is state of the art across all major AI memory benchmarks: @@ -350,6 +403,7 @@ Your app / AI tool - 📖 [Documentation](https://supermemory.ai/docs) - 🚀 [Quickstart](https://supermemory.ai/docs/quickstart) +- 🖥️ [Self-hosting (Supermemory local)](https://supermemory.ai/docs/self-hosting/overview) - 🧪 [MemoryBench](https://supermemory.ai/docs/memorybench/overview) - 🔌 [Integrations](https://supermemory.ai/docs/integrations) - 💬 [Discord](https://supermemory.link/discord) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..7cd710fb --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,364 @@ +

+ + + + Supermemory + +

+ +

+ 面向 AI 的记忆与上下文引擎,业界领先。也可以把它当作公司或个人的「大脑」来用。 +

+ +

+ 文档 · + 快速开始 · + 控制台 · + Discord +

+ +

+ npm + pypi + docs +

+ +

+ English · 简体中文 +

+ +--- + +Supermemory 是为 AI 设计的记忆与上下文层。在 **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)、[LoCoMo](https://github.com/snap-research/locomo) 和 [ConvoMem](https://github.com/Salesforce/ConvoMem)** 这三大 AI 记忆基准上均位列第一。 + +我们是一个研究团队,围绕这个引擎打造插件和工具生态。 + +你的 AI 在对话之间会把一切忘干净,Supermemory 就是来解决这个问题的。 + +它会自动从对话中学习,提取事实、构建用户画像、处理知识更新与冲突、淘汰过期信息,并在合适的时机交付合适的上下文。完整 RAG、连接器、文件处理——整个上下文栈,一套系统全部搞定。 + +| | | +|---|---| +| 🧠 **记忆** | 从对话中提取事实,处理时间变化、冲突,以及自动遗忘。 | +| 👤 **用户画像** | 自动维护的用户上下文——稳定事实 + 近期动态。一次调用,约 50ms。 | +| 🔍 **混合检索** | 一次查询同时跑 RAG 和记忆。知识库文档与个性化上下文一起返回。 | +| 🔌 **连接器** | Google Drive · Gmail · Notion · OneDrive · GitHub——实时 webhook 自动同步。 | +| 📄 **多模态抽取** | PDF、图片(OCR)、视频(转写)、代码(按 AST 切分)。上传即用。 | + +这一切都收敛在我们统一的记忆结构和本体里。 + +image + + +--- + +## 怎么用 Supermemory + + + + + + +
+ +

🧑‍💻 我只是 AI 工具的用户

+ +直接用我们的应用,给自己搭一份专属的 supermemory。它会**在每次对话之间维护一张持久的记忆图谱**。 + +你的 AI 会记住你的偏好、项目、历史讨论——而且越用越聪明。 + +**[→ 跳到用户安装指南](#给你的-ai-装上记忆)** + +
+ +

🔧 我在做 AI 产品

+ +通过**一套 API**,就能给你的 agent 和应用加上记忆、RAG、用户画像和连接器。 + +无需配置向量数据库,无需搭 embedding 流水线,无需操心切分策略。 + +**[→ 跳到开发者快速开始](#用-supermemory-api-构建)** + +
+ +--- + +## 给你的 AI 装上记忆 + +Supermemory 的应用、浏览器扩展、插件和 MCP 服务器,可以为任何兼容的 AI 助手提供持久记忆。装一次,AI 从此记住你。 + +### 应用 + +不用写代码,直接用我们面向消费者的应用——免费。 + +入口:https://app.supermemory.ai + +image + +应用里内置了一个 agent,我们叫它 Nova。 + +### Supermemory 插件 + +Supermemory 已经为 Claude Code、OpenCode、OpenClaw、Hermes 提供了开箱即用的插件。 + +image + +这些插件本质上是 supermemory API 的实现,全部开源: + +- Openclaw 插件:https://github.com/supermemoryai/openclaw-supermemory +- Claude Code 插件:https://github.com/supermemoryai/claude-supermemory +- OpenCode 插件:https://github.com/supermemoryai/opencode-supermemory +- Hermes agent(Supermemory 作为记忆 provider):https://github.com/NousResearch/hermes-agent + +### MCP——一键安装 + +```bash +npx -y install-mcp@latest https://mcp.supermemory.ai/mcp --client claude --oauth=yes +``` + +把 `claude` 换成你用的客户端即可:`cursor`、`windsurf`、`vscode` 等等。 + +更多 MCP 细节见:https://supermemory.ai/docs/supermemory-mcp/mcp + +### AI 接入后能用到什么 + +| 工具 | 作用 | +|---|---| +| `memory` | 保存或删除信息。每当你说出值得记住的内容,AI 会自动调用。 | +| `recall` | 按查询条件检索记忆。返回相关记忆 + 用户画像摘要。 | +| `context` | 在对话开始时把你完整的画像(偏好、近期活动)注入会话。Cursor 和 Claude Code 里输入 `/context` 即可。 | + +### 工作机制 + +装好之后,Supermemory 在后台运行: + +1. **正常和你的 AI 对话。** 聊聊偏好、提提项目、讨论一下问题。 +2. **Supermemory 自动提取并存下关键信息。** 事实、偏好、项目上下文——杂音不留。 +3. **下一次对话,AI 已经认识你了。** 它知道你在做什么、你喜欢怎么处理、你之前聊过什么。 + +记忆按 **project(容器标签)** 隔离,便于把工作和生活分开,也可以按客户、仓库或任何其它维度组织。 + +### 已支持的客户端 + +**Claude Desktop** · **Cursor** · **Windsurf** · **VS Code** · **Claude Code** · **OpenCode** · **OpenClaw** · **Hermes** + +MCP 服务器开源——[查看源码](https://supermemory.ai/docs/supermemory-mcp/mcp)。 + +### 手动配置 + +把下面这段加到你的 MCP 客户端配置里: + +```json +{ + "mcpServers": { + "supermemory": { + "url": "https://mcp.supermemory.ai/mcp" + } + } +} +``` + +如果想用 API key 代替 OAuth: + +```json +{ + "mcpServers": { + "supermemory": { + "url": "https://mcp.supermemory.ai/mcp", + "headers": { + "Authorization": "Bearer sm_your_api_key_here" + } + } + } +} +``` + +--- + +## 用 Supermemory API 构建 + +如果你在做 AI agent 或应用,Supermemory 用一套 API 给你完整的上下文栈——记忆、RAG、用户画像、连接器、文件处理,全包。 + +### 安装 + +```bash +npm install supermemory # 或者:pip install supermemory +``` + +### 快速开始 + +```typescript +import Supermemory from "supermemory"; + +const client = new Supermemory(); + +// 存一段对话 +await client.add({ + content: "User loves TypeScript and prefers functional patterns", + containerTag: "user_123", +}); + +// 一次调用拿到用户画像 + 相关记忆 +const { profile, searchResults } = await client.profile({ + containerTag: "user_123", + q: "What programming style does the user prefer?", +}); + +// profile.static → ["Loves TypeScript", "Prefers functional patterns"] +// profile.dynamic → ["Working on API integration"] +// searchResults → 按相似度排序的相关记忆 +``` + +```python +from supermemory import Supermemory + +client = Supermemory() + +client.add( + content="User loves TypeScript and prefers functional patterns", + container_tag="user_123" +) + +result = client.profile(container_tag="user_123", q="programming style") + +print(result.profile.static) # 长期事实 +print(result.profile.dynamic) # 近期上下文 +``` + +Supermemory 会自动抽取记忆、构建用户画像、返回相关上下文。不用搭 embedding 流水线,不用配向量库,不用操心切分。 + +### 框架集成 + +主流 AI 框架都有开箱即用的封装: + +```typescript +// Vercel AI SDK +import { withSupermemory } from "@supermemory/tools/ai-sdk"; +const model = withSupermemory(openai("gpt-4o"), { containerTag: "user_123", customId: "conv-1" }); + +// Mastra +import { withSupermemory } from "@supermemory/tools/mastra"; +const agent = new Agent(withSupermemory(config, "user-123", { mode: "full" })); +``` + +**Vercel AI SDK** · **LangChain** · **LangGraph** · **OpenAI Agents SDK** · **Mastra** · **Agno** · **Claude Memory Tool** · **n8n** + +### 检索模式 + +```typescript +// 混合检索(默认)——一次查询同时跑 RAG 和记忆 +const results = await client.search.memories({ + q: "how do I deploy?", + containerTag: "user_123", + searchMode: "hybrid", +}); +// 返回部署文档(RAG)+ 该用户的部署偏好(记忆) + +// 只查记忆 +const results = await client.search.memories({ + q: "user preferences", + containerTag: "user_123", + searchMode: "memories", +}); +``` + +### 用户画像 + +传统记忆方案依赖检索——你得先知道要问什么。Supermemory 会自动为每个用户维护一份画像: + +```typescript +const { profile } = await client.profile({ containerTag: "user_123" }); + +// profile.static → ["Senior engineer at Acme", "Prefers dark mode", "Uses Vim"] +// profile.dynamic → ["Working on auth migration", "Debugging rate limits"] +``` + +一次调用,约 50ms。把它塞到 system prompt 里,你的 agent 立刻就知道自己在和谁聊。 + +### 连接器 + +把外部数据自动同步进知识库: + +**Google Drive** · **Gmail** · **Notion** · **OneDrive** · **GitHub** · **网页爬取** + +实时 webhook。文档自动处理、切分、入索引。 + +### API 一览 + +| 方法 | 作用 | +|---|---| +| `client.add()` | 存储内容——文本、对话、URL、HTML | +| `client.profile()` | 一次调用返回用户画像 + 可选检索 | +| `client.search.memories()` | 跨记忆和文档的混合检索 | +| `client.search.documents()` | 带元数据过滤的文档检索 | +| `client.documents.uploadFile()` | 上传 PDF、图片、视频、代码 | +| `client.documents.list()` | 列出和筛选文档 | +| `client.settings.update()` | 配置记忆抽取与切分策略 | + +完整 API 文档 → [supermemory.ai/docs](https://supermemory.ai/docs) + +--- + +## 基准测试 + +在各大 AI 记忆基准上,Supermemory 都处于业界顶尖水平: + +| 基准 | 衡量内容 | 结果 | +|---|---|---| +| **[LongMemEval](https://github.com/xiaowu0162/LongMemEval)** | 跨会话的长期记忆能力,包含知识更新 | **81.6%——第 1** | +| **[LoCoMo](https://github.com/snap-research/locomo)** | 超长对话中的事实回忆(单跳、多跳、时序、对抗) | **第 1** | +| **[ConvoMem](https://github.com/Salesforce/ConvoMem)** | 个性化与偏好学习 | **第 1** | + +我们还做了 **[MemoryBench](https://supermemory.ai/docs/memorybench/overview)**——一个开源框架,用于对记忆服务做标准化、可复现的基准评测。Supermemory、Mem0、Zep 等可以直接放在一起对比: + +```bash +bun run src/index.ts run -p supermemory -b longmemeval -j gpt-4o -r my-run +``` + +### 给自己的记忆方案跑基准 + +我们提供了一个 Agent skill,方便公司把自己的上下文/记忆方案拿来和 supermemory 对比。 + +``` +npx skills add supermemoryai/memorybench +``` + +跑完上面这条命令,再执行 `/benchmark-context`,Supermemory 会自动帮你跑完整个流程。 + +--- + +## 记忆是怎么跑起来的 + +``` +你的应用 / AI 工具 + ↓ + Supermemory + │ + ├── 记忆引擎 提取事实、跟踪更新、解决冲突、 + │ 自动遗忘过期信息 + ├── 用户画像 基于引擎构建的静态事实 + 动态上下文,始终保持最新 + ├── 混合检索 一次查询同时跑 RAG 和记忆 + ├── 连接器 从 Google Drive、Gmail、Notion、GitHub 等实时同步 + └── 文件处理 PDF、图片、视频、代码 → 可检索的分块 +``` + +**记忆不等于 RAG。** RAG 是检索文档片段——无状态,对所有人返回相同结果。记忆是**长期跟踪和提取关于用户的事实**。它知道「我刚搬到 SF」会覆盖「我住在 NYC」。Supermemory 默认把两者一起跑,所以每次查询都能同时拿到知识库检索结果和个性化上下文。更多细节见:https://supermemory.ai/docs/concepts/memory-vs-rag + +**自动遗忘。** Supermemory 知道哪些记忆已经无关紧要。临时事实(「我明天有考试」)会在日期过后失效,冲突会自动消解,杂音不会沉淀成永久记忆。 + +--- + +## 链接 + +- 📖 [文档](https://supermemory.ai/docs) +- 🚀 [快速开始](https://supermemory.ai/docs/quickstart) +- 🧪 [MemoryBench](https://supermemory.ai/docs/memorybench/overview) +- 🔌 [集成](https://supermemory.ai/docs/integrations) +- 💬 [Discord](https://supermemory.link/discord) +- 𝕏 [Twitter](https://twitter.com/supermemory) + +--- + +

+ 是时候给你的 AI 一份记忆了。 +

diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts index e6dba3eb..f9f9c979 100644 --- a/apps/browser-extension/entrypoints/background.ts +++ b/apps/browser-extension/entrypoints/background.ts @@ -148,7 +148,17 @@ export default defineBackground(() => { eventSource: string, ): Promise<{ success: boolean; data?: unknown; error?: string }> => { try { - const responseData = await searchMemories(data) + let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT + try { + const defaultProject = await getDefaultProject() + if (defaultProject?.containerTag) { + containerTag = defaultProject.containerTag + } + } catch (error) { + console.warn("Failed to get default project, using fallback:", error) + } + + const responseData = await searchMemories(data, containerTag) const response = responseData as { results?: Array<{ memory?: string }> } diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts index d124c84a..4fe41cbd 100644 --- a/apps/browser-extension/entrypoints/content/claude.ts +++ b/apps/browser-extension/entrypoints/content/claude.ts @@ -29,6 +29,7 @@ export function initializeClaude() { } setTimeout(() => { + addSupermemoryButtonToClaudeMemoryDialog() addSupermemoryIconToClaudeInput() setupClaudeAutoFetch() }, 2000) @@ -59,6 +60,7 @@ function setupClaudeRouteChangeDetection() { currentUrl = window.location.href console.log("Claude route changed, re-adding supermemory icon") setTimeout(() => { + addSupermemoryButtonToClaudeMemoryDialog() addSupermemoryIconToClaudeInput() setupClaudeAutoFetch() }, 1000) @@ -79,10 +81,13 @@ function setupClaudeRouteChangeDetection() { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element if ( + element.querySelector?.('[role="dialog"]') || element.querySelector?.('div[contenteditable="true"]') || element.querySelector?.("textarea") || + element.matches?.('[role="dialog"]') || element.matches?.('div[contenteditable="true"]') || - element.matches?.("textarea") + element.matches?.("textarea") || + element.textContent?.includes("Manage memory") ) { shouldRecheck = true } @@ -95,6 +100,7 @@ function setupClaudeRouteChangeDetection() { claudeObserverThrottle = setTimeout(() => { try { claudeObserverThrottle = null + addSupermemoryButtonToClaudeMemoryDialog() addSupermemoryIconToClaudeInput() setupClaudeAutoFetch() } catch (error) { @@ -264,6 +270,190 @@ async function getRelatedMemoriesForClaude(actionSource: string) { } } +function getClaudeMemoryDialog(): HTMLElement | null { + const dialogs = Array.from( + document.querySelectorAll('[role="dialog"]'), + ) + + for (const dialog of dialogs) { + const heading = Array.from(dialog.querySelectorAll("h1, h2, h3")).find( + (element) => element.textContent?.trim() === "Manage memory", + ) + if (heading) return dialog + } + + const candidates = Array.from(document.querySelectorAll("div")) + .filter((element) => { + const text = element.textContent || "" + if ( + !text.includes("Manage memory") || + !text.includes("Here's what Claude remembers") + ) { + return false + } + + const rect = element.getBoundingClientRect() + return rect.width > 400 && rect.height > 250 + }) + .sort((a, b) => { + const rectA = a.getBoundingClientRect() + const rectB = b.getBoundingClientRect() + return rectA.width * rectA.height - rectB.width * rectB.height + }) + + return candidates[0] || null +} + +function getClaudeMemoryText(dialog: HTMLElement): string { + const clonedDialog = dialog.cloneNode(true) as HTMLElement + clonedDialog.querySelector("#supermemory-save-button")?.remove() + + const sanitizeClaudeMemoryText = (text: string) => + text + .replace(/^Memories from Claude:\s*/i, "") + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => + line && + line !== "Tell Claude what to remember or forget..." && + line !== "Save to supermemory", + ) + .join("\n") + .trim() + + const memorySections = Array.from( + clonedDialog.querySelectorAll( + "article, section, [class*='border'], [class*='rounded']", + ), + ) + .map((element) => element.innerText || element.textContent || "") + .map(sanitizeClaudeMemoryText) + .filter((text) => { + return ( + text.length > 80 && + !text.includes("Manage edits") && + !text.includes("Save to supermemory") && + !text.includes("Tell Claude what to remember or forget") + ) + }) + .sort((a, b) => b.length - a.length) + + if (memorySections[0]) return memorySections[0] + + return sanitizeClaudeMemoryText( + clonedDialog.innerText || clonedDialog.textContent || "", + ) +} + +function addSupermemoryButtonToClaudeMemoryDialog() { + const memoryDialog = getClaudeMemoryDialog() + if (!memoryDialog) return + + if (memoryDialog.querySelector("#supermemory-save-button")) return + + const supermemoryButton = document.createElement("button") + supermemoryButton.id = "supermemory-save-button" + + const iconUrl = browser.runtime.getURL("/icon-16.png") + + supermemoryButton.innerHTML = ` +
+ supermemory + Save to supermemory +
+ ` + + supermemoryButton.style.cssText = ` + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: auto !important; + min-width: 190px !important; + background: #1C2026 !important; + color: white !important; + border: 1px solid #1C2026 !important; + border-radius: 9999px !important; + padding: 10px 16px !important; + font-weight: 500 !important; + font-size: 14px !important; + line-height: 20px !important; + white-space: nowrap !important; + margin: 8px 0 8px 0 !important; + transform: translateX(-16px) !important; + cursor: pointer !important; + font-family: inherit !important; + ` + + supermemoryButton.addEventListener("mouseenter", () => { + supermemoryButton.style.backgroundColor = "#2B2E33" + }) + + supermemoryButton.addEventListener("mouseleave", () => { + supermemoryButton.style.backgroundColor = "#1C2026" + }) + + supermemoryButton.addEventListener("click", async () => { + await saveClaudeMemoriesToSupermemory(memoryDialog) + }) + + const introText = Array.from( + memoryDialog.querySelectorAll("p, div"), + ).find((element) => + element.textContent?.includes("Here's what Claude remembers"), + ) + + if (introText?.parentElement) { + introText.parentElement.insertBefore( + supermemoryButton, + introText.nextSibling, + ) + return + } + + const heading = Array.from(memoryDialog.querySelectorAll("h1, h2, h3")).find( + (element) => element.textContent?.trim() === "Manage memory", + ) + + if (heading?.parentElement) { + heading.parentElement.insertBefore(supermemoryButton, heading.nextSibling) + return + } + + memoryDialog.insertBefore(supermemoryButton, memoryDialog.firstChild) +} + +async function saveClaudeMemoriesToSupermemory(memoryDialog: HTMLElement) { + try { + DOMUtils.showToast("loading") + + const memoryText = getClaudeMemoryText(memoryDialog) + if (!memoryText) { + DOMUtils.showToast("error") + return + } + + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.SAVE_MEMORY, + data: { + html: memoryText, + }, + actionSource: "claude_memories_dialog", + }) + + console.log({ response }) + + if (response.success) { + DOMUtils.showToast("success") + } else { + DOMUtils.showToast("error") + } + } catch (error) { + console.error("Error saving Claude memories to supermemory:", error) + DOMUtils.showToast("error") + } +} + function updateClaudeIconFeedback( message: string, iconElement: HTMLElement, diff --git a/apps/browser-extension/entrypoints/content/grok.ts b/apps/browser-extension/entrypoints/content/grok.ts new file mode 100644 index 00000000..378ad750 --- /dev/null +++ b/apps/browser-extension/entrypoints/content/grok.ts @@ -0,0 +1,445 @@ +import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants" +import { DOMUtils } from "../../utils/ui-components" + +let grokRouteObserver: MutationObserver | null = null +let grokUrlCheckInterval: NodeJS.Timeout | null = null +let grokObserverThrottle: NodeJS.Timeout | null = null +const GROK_IMPORT_INTENT_PARAM = "sm_grok_import" +const GROK_IMPORT_INTENT_VALUE = "memories" + +export function initializeGrok() { + if (!DOMUtils.isOnDomain(DOMAINS.GROK)) { + return + } + + if (document.body.hasAttribute("data-grok-initialized")) { + return + } + + setTimeout(() => { + addSupermemoryButtonToGrokMemoryDialog() + handleGrokImportIntent() + }, 1000) + + setupGrokRouteChangeDetection() + + document.body.setAttribute("data-grok-initialized", "true") +} + +function setupGrokRouteChangeDetection() { + if (grokRouteObserver) { + grokRouteObserver.disconnect() + } + if (grokUrlCheckInterval) { + clearInterval(grokUrlCheckInterval) + } + if (grokObserverThrottle) { + clearTimeout(grokObserverThrottle) + grokObserverThrottle = null + } + + let currentUrl = window.location.href + + const checkForRouteChange = () => { + if (window.location.href !== currentUrl) { + currentUrl = window.location.href + setTimeout(() => { + addSupermemoryButtonToGrokMemoryDialog() + handleGrokImportIntent() + }, 500) + } + } + + grokUrlCheckInterval = setInterval(checkForRouteChange, 2000) + + grokRouteObserver = new MutationObserver((mutations) => { + if (grokObserverThrottle) { + return + } + + let shouldRecheck = false + for (const mutation of mutations) { + if (mutation.type !== "childList" || mutation.addedNodes.length === 0) { + continue + } + + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) { + continue + } + + const element = node as Element + const text = element.textContent || "" + if ( + element.querySelector?.('[role="dialog"]') || + element.matches?.('[role="dialog"]') || + text.includes("Data Controls") || + text.includes("Settings") || + text.includes("Memory from your chats") + ) { + shouldRecheck = true + break + } + } + } + + if (shouldRecheck) { + grokObserverThrottle = setTimeout(() => { + grokObserverThrottle = null + addSupermemoryButtonToGrokMemoryDialog() + handleGrokImportIntent() + }, 250) + } + }) + + try { + grokRouteObserver.observe(document.body, { + childList: true, + subtree: true, + }) + } catch (error) { + console.error("Failed to set up Grok route observer:", error) + if (grokUrlCheckInterval) { + clearInterval(grokUrlCheckInterval) + } + grokUrlCheckInterval = setInterval(checkForRouteChange, 1000) + } +} + +function hasGrokImportIntent() { + return ( + new URLSearchParams(window.location.search).get( + GROK_IMPORT_INTENT_PARAM, + ) === GROK_IMPORT_INTENT_VALUE + ) +} + +function clearGrokImportIntent() { + const url = new URL(window.location.href) + url.searchParams.delete(GROK_IMPORT_INTENT_PARAM) + window.history.replaceState(window.history.state, "", url.toString()) +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function isVisible(element: HTMLElement) { + const rect = element.getBoundingClientRect() + const style = window.getComputedStyle(element) + + return ( + rect.width > 0 && + rect.height > 0 && + style.display !== "none" && + style.visibility !== "hidden" && + Number.parseFloat(style.opacity || "1") > 0 + ) +} + +function getNormalizedText(element: Element) { + return (element.textContent || "").replace(/\s+/g, " ").trim() +} + +function clickVisibleElementByText( + labels: string[], + root: ParentNode = document, +) { + const elements = Array.from( + root.querySelectorAll( + "button, a, [role='button'], [role='tab'], [data-testid], div, span", + ), + ) + + for (const label of labels) { + const matchingElement = elements.find((element) => { + const text = getNormalizedText(element) + return text === label && isVisible(element) + }) + + if (!matchingElement) { + continue + } + + const clickableElement = + matchingElement.closest( + "button, a, [role='button'], [role='tab']", + ) || matchingElement + + clickableElement.click() + return true + } + + return false +} + +function getGrokSettingsDialog() { + return Array.from( + document.querySelectorAll('[role="dialog"]'), + ).find((dialog) => { + const text = getNormalizedText(dialog) + return ( + isVisible(dialog) && + text.includes("Data Controls") && + text.includes("Appearance") && + text.includes("Behavior") + ) + }) +} + +function isGrokDataControlsVisible() { + const text = getNormalizedText(document.body) + return ( + text.includes("Data Controls") && text.includes("Memory from your chats") + ) +} + +async function handleGrokImportIntent() { + if (!hasGrokImportIntent()) return + + if (document.body.hasAttribute("data-grok-import-intent-running")) { + return + } + + document.body.setAttribute("data-grok-import-intent-running", "true") + + for (let attempt = 0; attempt < 24; attempt++) { + addSupermemoryButtonToGrokMemoryDialog() + + if (getGrokMemoryDialog()) { + clearGrokImportIntent() + document.body.removeAttribute("data-grok-import-intent-running") + return + } + + const settingsDialog = getGrokSettingsDialog() + if (settingsDialog) { + if (isGrokDataControlsVisible()) { + clearGrokImportIntent() + document.body.removeAttribute("data-grok-import-intent-running") + return + } + + clickVisibleElementByText(["Data Controls"], settingsDialog) + } else { + clickVisibleElementByText(["Settings"], document) + } + + await sleep(350) + } + + document.body.removeAttribute("data-grok-import-intent-running") +} + +function getGrokMemoryDialog(): HTMLElement | null { + const dialogs = Array.from( + document.querySelectorAll('[role="dialog"]'), + ) + + for (const dialog of dialogs) { + const heading = Array.from(dialog.querySelectorAll("h1, h2, h3")).find( + (element) => element.textContent?.trim() === "Memory from your chats", + ) + if (heading) return dialog + } + + const candidates = Array.from(document.querySelectorAll("div")) + .filter((element) => { + const text = element.textContent || "" + if ( + !text.includes("Memory from your chats") || + !text.includes("This summary is regenerated") + ) { + return false + } + + const rect = element.getBoundingClientRect() + return rect.width > 400 && rect.height > 250 + }) + .sort((a, b) => { + const rectA = a.getBoundingClientRect() + const rectB = b.getBoundingClientRect() + return rectA.width * rectA.height - rectB.width * rectB.height + }) + + return candidates[0] || null +} + +const GROK_MEMORY_UI_TEXT = [ + "Memory from your chats", + "This summary is regenerated periodically from your conversations.", + "Save to supermemory", + "Close", + "Delete memory", + "Edit", +] as const + +function escapeRegExp(text: string) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function sanitizeGrokMemoryText(text: string) { + let sanitizedText = text + + for (const uiText of GROK_MEMORY_UI_TEXT) { + sanitizedText = sanitizedText.replace( + new RegExp(escapeRegExp(uiText), "g"), + "\n", + ) + } + + return sanitizedText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line) + .join("\n") + .trim() +} + +function getGrokMemoryText(dialog: HTMLElement): string { + const clonedDialog = dialog.cloneNode(true) as HTMLElement + clonedDialog.querySelector("#supermemory-save-button")?.remove() + + const possibleMemoryContainers = Array.from( + clonedDialog.querySelectorAll( + "article, section, [class*='overflow'], [class*='prose'], [class*='whitespace']", + ), + ) + .map((element) => element.innerText || element.textContent || "") + .map(sanitizeGrokMemoryText) + .filter((text) => text.length > 30) + .sort((a, b) => b.length - a.length) + + if (possibleMemoryContainers[0]) { + return possibleMemoryContainers[0] + } + + return sanitizeGrokMemoryText( + clonedDialog.innerText || clonedDialog.textContent || "", + ) +} + +function createSupermemoryButton(memoryDialog: HTMLElement) { + const supermemoryButton = document.createElement("button") + supermemoryButton.id = "supermemory-save-button" + + const iconUrl = browser.runtime.getURL("/icon-16.png") + + supermemoryButton.innerHTML = ` +
+ supermemory + Save to supermemory +
+ ` + + supermemoryButton.style.cssText = ` + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: auto !important; + min-width: 190px !important; + background: #1C2026 !important; + color: white !important; + border: 1px solid #1C2026 !important; + border-radius: 9999px !important; + padding: 10px 16px !important; + font-weight: 500 !important; + font-size: 14px !important; + line-height: 20px !important; + white-space: nowrap !important; + cursor: pointer !important; + font-family: inherit !important; + z-index: 1 !important; + ` + + supermemoryButton.addEventListener("mouseenter", () => { + supermemoryButton.style.backgroundColor = "#2B2E33" + }) + + supermemoryButton.addEventListener("mouseleave", () => { + supermemoryButton.style.backgroundColor = "#1C2026" + }) + + supermemoryButton.addEventListener("click", async () => { + await saveGrokMemoriesToSupermemory(memoryDialog) + }) + + return supermemoryButton +} + +function addSupermemoryButtonToGrokMemoryDialog() { + const memoryDialog = getGrokMemoryDialog() + if (!memoryDialog) return + + if (memoryDialog.querySelector("#supermemory-save-button")) return + + const supermemoryButton = createSupermemoryButton(memoryDialog) + + const heading = Array.from(memoryDialog.querySelectorAll("h1, h2, h3")).find( + (element) => element.textContent?.trim() === "Memory from your chats", + ) + + const closeButton = Array.from( + memoryDialog.querySelectorAll("button"), + ).find((button) => { + const label = button.getAttribute("aria-label")?.toLowerCase() || "" + const text = button.textContent?.trim().toLowerCase() || "" + return label.includes("close") || text === "×" || text === "x" + }) + + if (heading?.parentElement) { + const header = heading.parentElement + header.style.display = "flex" + header.style.alignItems = "center" + header.style.gap = "12px" + + const spacer = document.createElement("div") + spacer.style.flex = "1" + + if (closeButton?.parentElement === header) { + header.insertBefore(spacer, closeButton) + header.insertBefore(supermemoryButton, closeButton) + } else { + header.appendChild(spacer) + header.appendChild(supermemoryButton) + } + return + } + + if (closeButton?.parentElement) { + closeButton.parentElement.insertBefore(supermemoryButton, closeButton) + return + } + + memoryDialog.insertBefore(supermemoryButton, memoryDialog.firstChild) +} + +async function saveGrokMemoriesToSupermemory(memoryDialog: HTMLElement) { + try { + DOMUtils.showToast("loading") + + const memoryText = getGrokMemoryText(memoryDialog) + if (!memoryText) { + DOMUtils.showToast("error") + return + } + + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.SAVE_MEMORY, + data: { + content: memoryText, + title: "Grok memories import", + }, + actionSource: "grok_memories_dialog", + }) + + if (response.success) { + DOMUtils.showToast("success") + } else { + DOMUtils.showToast("error") + } + } catch (error) { + console.error("Error saving Grok memories to supermemory:", error) + DOMUtils.showToast("error") + } +} diff --git a/apps/browser-extension/entrypoints/content/index.ts b/apps/browser-extension/entrypoints/content/index.ts index cba33ce2..1c863530 100644 --- a/apps/browser-extension/entrypoints/content/index.ts +++ b/apps/browser-extension/entrypoints/content/index.ts @@ -2,6 +2,7 @@ import { DOMAINS, MESSAGE_TYPES } from "../../utils/constants" import { DOMUtils } from "../../utils/ui-components" import { initializeChatGPT } from "./chatgpt" import { initializeClaude } from "./claude" +import { initializeGrok } from "./grok" import { saveMemory, setupGlobalKeyboardShortcut, @@ -48,6 +49,9 @@ export default defineContentScript({ if (DOMUtils.isOnDomain(DOMAINS.CLAUDE)) { initializeClaude() } + if (DOMUtils.isOnDomain(DOMAINS.GROK)) { + initializeGrok() + } if (DOMUtils.isOnDomain(DOMAINS.T3)) { initializeT3() } @@ -65,6 +69,7 @@ export default defineContentScript({ // Initialize platform-specific functionality initializeChatGPT() initializeClaude() + initializeGrok() initializeT3() initializeTwitter() diff --git a/apps/browser-extension/entrypoints/popup/App.tsx b/apps/browser-extension/entrypoints/popup/App.tsx index 498a75a2..bcc2a910 100644 --- a/apps/browser-extension/entrypoints/popup/App.tsx +++ b/apps/browser-extension/entrypoints/popup/App.tsx @@ -70,6 +70,167 @@ const Tooltip = ({ ) } +const cardShadow = + "2px 2px 2px 0 rgba(0, 0, 0, 0.50) inset, -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset" + +type ManualImportProvider = "gemini" + +const manualImportProviderConfig: Record< + ManualImportProvider, + { label: string; actionSource: string } +> = { + gemini: { + label: "Gemini", + actionSource: "gemini_manual_memory_import", + }, +} + +const manualMemoryImportPrompt = `Export all of my stored memories and any context you've learned about me from past conversations. Preserve my words verbatim where possible, especially for instructions and preferences. + +## Categories (output in this order): + +1. **Instructions**: Rules I've explicitly asked you to follow going forward - tone, format, style, "always do X", "never do Y", and corrections to your behavior. Only include rules from stored memories, not from conversations. + +2. **Identity**: Name, age, location, education, family, relationships, languages, and personal interests. + +3. **Career**: Current and past roles, companies, and general skill areas. + +4. **Projects**: Projects I meaningfully built or committed to. Ideally ONE entry per project. Include what it does, current status, and any key decisions. Use the project name or a short descriptor as the first words of the entry. + +5. **Preferences**: Opinions, tastes, and working-style preferences that apply broadly. + +## Format: + +Use section headers for each category. Within each category, list one entry per line, sorted by oldest date first. Format each line as: + +[YYYY-MM-DD] - Entry content here. + +If no date is known, use [unknown] instead. + +## Output: +- Wrap the entire export in a single code block for easy copying. +- After the code block, state whether this is the complete set or if more remain.` + +const normalizeManualMemoryImport = (value: string) => { + const trimmed = value.trim() + const codeBlockMatch = trimmed.match(/```(?:[\w-]+)?\s*([\s\S]*?)```/) + + return (codeBlockMatch?.[1] ?? trimmed).trim() +} + +const OpenAILogo = ({ className }: { className?: string }) => ( + + OpenAI + + +) + +const ClaudeLogo = ({ className }: { className?: string }) => ( + Claude +) + +const GeminiLogo = ({ className }: { className?: string }) => ( + Gemini +) + +const XLogo = ({ className }: { className?: string }) => ( + + X Twitter Logo + + +) + +const GrokLogo = ({ className }: { className?: string }) => ( + + Grok + + + + +) + +const ChatAppsLogo = ({ className }: { className?: string }) => ( +
+
+ +
+
+ +
+
+ +
+
+) + +const ImportCard = ({ + icon, + title, + description, + onClick, +}: { + icon: React.ReactNode + title: string + description?: string + onClick: () => void +}) => ( + +) + function App() { const [userSignedIn, setUserSignedIn] = useState(false) const [loading, setLoading] = useState(true) @@ -80,6 +241,14 @@ function App() { const [activeTab, setActiveTab] = useState<"save" | "imports" | "settings">( "save", ) + const [showChatAppImports, setShowChatAppImports] = useState(false) + const [manualImportProvider, setManualImportProvider] = + useState(null) + const [manualImportText, setManualImportText] = useState("") + const [manualImportSaving, setManualImportSaving] = useState(false) + const [manualImportSaved, setManualImportSaved] = useState(false) + const [manualImportCopied, setManualImportCopied] = useState(false) + const [manualImportError, setManualImportError] = useState("") const [autoSearchEnabled, setAutoSearchEnabled] = useState(false) const [autoCapturePromptsEnabled, setAutoCapturePromptsEnabled] = useState(false) @@ -175,16 +344,33 @@ function App() { setShowProjectSelector(true) } + // Reconcile stored default against live list: reset if deleted, refresh if renamed. useEffect(() => { - if (!defaultProject && projects.length > 0) { - const firstProject = projects[0] - setDefaultProjectMutation.mutate(firstProject) + if (projects.length === 0) return + if (!defaultProject) { + setDefaultProjectMutation.mutate(projects[0]) + return + } + const live = projects.find((p) => p.id === defaultProject.id) + if (!live) { + setDefaultProjectMutation.mutate(projects[0]) + } else if ( + live.name !== defaultProject.name || + live.containerTag !== defaultProject.containerTag + ) { + setDefaultProjectMutation.mutate(live) } }, [defaultProject, projects, setDefaultProjectMutation]) // biome-ignore lint/correctness/useExhaustiveDependencies: close space selector when tab changes useEffect(() => { setShowProjectSelector(false) + setShowChatAppImports(false) + setManualImportProvider(null) + setManualImportText("") + setManualImportSaved(false) + setManualImportCopied(false) + setManualImportError("") }, [activeTab]) const handleSaveCurrentPage = async () => { @@ -252,6 +438,125 @@ function App() { } } + const handleTwitterBookmarksImport = async () => { + const targetUrl = "https://x.com/i/bookmarks" + + try { + const [activeTab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }) + + const isOnBookmarksPage = + activeTab?.url?.includes("x.com/i/bookmarks") || + activeTab?.url?.includes("twitter.com/i/bookmarks") + + if (isOnBookmarksPage && activeTab?.id) { + try { + await chrome.tabs.sendMessage(activeTab.id, { + action: MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL, + }) + } catch (error) { + console.error("Failed to send message to content script:", error) + const intentExpiry = Date.now() + UI_CONFIG.IMPORT_INTENT_TTL + await chrome.storage.local.set({ + [STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]: intentExpiry, + }) + await chrome.tabs.create({ + url: targetUrl, + }) + } + } else { + const intentExpiry = Date.now() + UI_CONFIG.IMPORT_INTENT_TTL + await chrome.storage.local.set({ + [STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL]: intentExpiry, + }) + await chrome.tabs.create({ + url: targetUrl, + }) + } + } catch (error) { + console.error("Error opening Twitter import:", error) + try { + await chrome.tabs.create({ + url: targetUrl, + }) + } catch (fallbackError) { + console.error("Failed to open bookmarks page:", fallbackError) + } + } + } + + const handleOpenManualMemoryImport = (provider: ManualImportProvider) => { + setManualImportProvider(provider) + setManualImportText("") + setManualImportSaved(false) + setManualImportCopied(false) + setManualImportError("") + } + + const handleCloseManualMemoryImport = () => { + setManualImportProvider(null) + setManualImportText("") + setManualImportSaved(false) + setManualImportCopied(false) + setManualImportError("") + } + + const handleCopyManualImportPrompt = async () => { + try { + await navigator.clipboard.writeText(manualMemoryImportPrompt) + setManualImportCopied(true) + window.setTimeout(() => setManualImportCopied(false), 1600) + } catch (error) { + console.error("Failed to copy memory import prompt:", error) + setManualImportError( + "Could not copy prompt. Select and copy it manually.", + ) + } + } + + const handleManualMemoryImportSave = async () => { + if (!manualImportProvider) return + + const content = normalizeManualMemoryImport(manualImportText) + if (!content) { + setManualImportError("Paste the exported memories first.") + return + } + + setManualImportSaving(true) + setManualImportError("") + + try { + const providerConfig = manualImportProviderConfig[manualImportProvider] + const response = await chrome.runtime.sendMessage({ + action: MESSAGE_TYPES.SAVE_MEMORY, + actionSource: providerConfig.actionSource, + data: { + content, + title: `${providerConfig.label} memories import`, + }, + }) + + if (!response?.success) { + throw new Error(response?.error || "Could not add memories") + } + + setManualImportSaved(true) + window.setTimeout(() => { + handleCloseManualMemoryImport() + }, 1000) + } catch (error) { + console.error("Failed to add manual memory import:", error) + setManualImportError( + error instanceof Error ? error.message : "Could not add memories", + ) + } finally { + setManualImportSaving(false) + } + } + const handleSignOut = async () => { try { await Promise.all([ @@ -630,136 +935,238 @@ function App() { ) : activeTab === "imports" ? (
- {/* Import Actions */} -
-
- +
+ +
+
+ + 1 + + Copy this prompt into chat +
+
+
+													{manualMemoryImportPrompt}
+												
+
+ +
+
+ +
+
+ + 2 + + Paste results below +
+