Compare commits
42 commits
server-v0.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5258cb74c8 | ||
|
|
4d8a4ebfdd | ||
|
|
9a0c5a5ad6 | ||
|
|
17eab43cd4 | ||
|
|
4ad5f0beb1 | ||
|
|
03773c4f2e | ||
|
|
d0f53b0d64 | ||
|
|
b01d2b69a3 | ||
|
|
7974498062 | ||
|
|
4173edb9e6 | ||
|
|
c262cc9953 | ||
|
|
879ddd5c95 | ||
|
|
46d1b53230 | ||
|
|
de3bbb3ce9 | ||
|
|
5fee2f2872 | ||
|
|
ece20ff53e | ||
|
|
348483d5e8 | ||
|
|
c5b7e7d4fc | ||
|
|
143024fa34 | ||
|
|
9ccd1b64c3 | ||
|
|
d436792e77 | ||
|
|
29c43984fe | ||
|
|
f11d8c4620 | ||
|
|
9652478093 | ||
|
|
3f7b9667c6 | ||
|
|
e4afc770be | ||
|
|
f051af098e | ||
|
|
6cae175852 | ||
|
|
3b0fc9c959 | ||
|
|
3487666481 | ||
|
|
dda56e766e | ||
|
|
818a83a381 | ||
|
|
7b1175cb1a | ||
|
|
20410a6862 | ||
|
|
7d59070ad6 | ||
|
|
18a2dfbe39 | ||
|
|
149589ae7e | ||
|
|
c0eb81c887 | ||
|
|
e2be9c9edd | ||
|
|
5d2b5855fe | ||
|
|
d14b209f7c | ||
|
|
e651045ac5 |
257
.github/workflows/ci-python.yml
vendored
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
name: CI - Python SDKs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "packages/agent-framework-python/**"
|
||||||
|
- "packages/cartesia-sdk-python/**"
|
||||||
|
- "packages/openai-sdk-python/**"
|
||||||
|
- "packages/pipecat-sdk-python/**"
|
||||||
|
- ".github/workflows/ci-python.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
agent-framework-python:
|
||||||
|
name: agent-framework-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- python-version: "3.10"
|
||||||
|
dependency-lane: minimum-supermemory
|
||||||
|
supermemory-version: "3.16.0"
|
||||||
|
- python-version: "3.13"
|
||||||
|
dependency-lane: current-supermemory
|
||||||
|
supermemory-version: "3.59.0"
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: packages/agent-framework-python
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: packages/agent-framework-python/pyproject.toml
|
||||||
|
|
||||||
|
- name: Install build and test tools
|
||||||
|
run: python -m pip install build pytest pytest-asyncio
|
||||||
|
|
||||||
|
- name: Build wheel
|
||||||
|
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||||
|
|
||||||
|
- name: Install wheel and tested Supermemory SDK
|
||||||
|
run: >-
|
||||||
|
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
|
||||||
|
"supermemory==${{ matrix.supermemory-version }}"
|
||||||
|
|
||||||
|
- name: Check dependency compatibility
|
||||||
|
run: python -m pip check
|
||||||
|
|
||||||
|
- name: Verify installed wheel and SDK version
|
||||||
|
run: >-
|
||||||
|
python -c "from importlib.metadata import version; from pathlib import Path;
|
||||||
|
import supermemory_agent_framework;
|
||||||
|
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||||
|
assert 'site-packages' in Path(supermemory_agent_framework.__file__).parts"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: python -m pytest
|
||||||
|
|
||||||
|
openai-sdk-python:
|
||||||
|
name: openai-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- python-version: "3.9"
|
||||||
|
dependency-lane: minimum-supermemory
|
||||||
|
supermemory-version: "3.50.0"
|
||||||
|
expected-supermemory-version: "3.50.0"
|
||||||
|
- python-version: "3.12"
|
||||||
|
dependency-lane: locked
|
||||||
|
supermemory-version: ""
|
||||||
|
expected-supermemory-version: "3.59.0"
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: packages/openai-sdk-python
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Setup uv
|
||||||
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: true
|
||||||
|
working-directory: packages/openai-sdk-python
|
||||||
|
cache-dependency-glob: uv.lock
|
||||||
|
|
||||||
|
- name: Install locked dependencies
|
||||||
|
run: uv sync --locked --python "${{ matrix.python-version }}"
|
||||||
|
|
||||||
|
- name: Build wheel
|
||||||
|
run: uv build --wheel --out-dir "$RUNNER_TEMP/wheels"
|
||||||
|
|
||||||
|
- name: Install built wheel
|
||||||
|
run: >-
|
||||||
|
uv pip install --python .venv/bin/python --reinstall --no-deps
|
||||||
|
"$RUNNER_TEMP"/wheels/*.whl
|
||||||
|
|
||||||
|
- name: Install minimum Supermemory SDK
|
||||||
|
if: matrix.supermemory-version != ''
|
||||||
|
run: >-
|
||||||
|
uv pip install --python .venv/bin/python
|
||||||
|
"supermemory==${{ matrix.supermemory-version }}"
|
||||||
|
|
||||||
|
- name: Check dependency compatibility
|
||||||
|
run: uv pip check --python .venv/bin/python
|
||||||
|
|
||||||
|
- name: Verify installed wheel and SDK version
|
||||||
|
run: >-
|
||||||
|
.venv/bin/python -c "from importlib.metadata import version;
|
||||||
|
from pathlib import Path; import supermemory_openai;
|
||||||
|
assert version('supermemory') == '${{ matrix.expected-supermemory-version }}';
|
||||||
|
assert 'site-packages' in Path(supermemory_openai.__file__).parts"
|
||||||
|
|
||||||
|
- name: Run tests without changing the verified environment
|
||||||
|
run: .venv/bin/python -m pytest
|
||||||
|
|
||||||
|
cartesia-sdk-python:
|
||||||
|
name: cartesia-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- python-version: "3.10"
|
||||||
|
dependency-lane: minimum-dependencies
|
||||||
|
supermemory-version: "3.16.0"
|
||||||
|
cartesia-line-version: "0.2.0"
|
||||||
|
- python-version: "3.12"
|
||||||
|
dependency-lane: current-dependencies
|
||||||
|
supermemory-version: "3.59.0"
|
||||||
|
cartesia-line-version: "0.2.17"
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: packages/cartesia-sdk-python
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: packages/cartesia-sdk-python/pyproject.toml
|
||||||
|
|
||||||
|
- name: Install build and test tools
|
||||||
|
run: python -m pip install build pytest
|
||||||
|
|
||||||
|
- name: Build wheel
|
||||||
|
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||||
|
|
||||||
|
- name: Install wheel and tested runtime dependencies
|
||||||
|
run: >-
|
||||||
|
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
|
||||||
|
"supermemory==${{ matrix.supermemory-version }}"
|
||||||
|
"cartesia-line==${{ matrix.cartesia-line-version }}"
|
||||||
|
|
||||||
|
- name: Check dependency compatibility
|
||||||
|
run: python -m pip check
|
||||||
|
|
||||||
|
- name: Verify real Cartesia Line integration and run tests
|
||||||
|
run: >-
|
||||||
|
python -c "from importlib.metadata import version;
|
||||||
|
from pathlib import Path; import line, pytest, supermemory_cartesia;
|
||||||
|
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||||
|
assert version('cartesia-line') == '${{ matrix.cartesia-line-version }}';
|
||||||
|
assert 'site-packages' in Path(supermemory_cartesia.__file__).parts;
|
||||||
|
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
|
||||||
|
raise SystemExit(result)"
|
||||||
|
|
||||||
|
pipecat-sdk-python:
|
||||||
|
name: pipecat-sdk-python (${{ matrix.dependency-lane }}, Python ${{ matrix.python-version }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- python-version: "3.10"
|
||||||
|
dependency-lane: minimum-dependencies
|
||||||
|
supermemory-version: "3.16.0"
|
||||||
|
pipecat-version: "0.0.98"
|
||||||
|
- python-version: "3.12"
|
||||||
|
dependency-lane: current-dependencies
|
||||||
|
supermemory-version: "3.59.0"
|
||||||
|
pipecat-version: "1.7.0"
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: packages/pipecat-sdk-python
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: packages/pipecat-sdk-python/pyproject.toml
|
||||||
|
|
||||||
|
- name: Install build and test tools
|
||||||
|
run: python -m pip install build pytest
|
||||||
|
|
||||||
|
- name: Build wheel
|
||||||
|
run: python -m build --wheel --outdir "$RUNNER_TEMP/wheels"
|
||||||
|
|
||||||
|
- name: Install wheel and tested runtime dependencies
|
||||||
|
run: >-
|
||||||
|
python -m pip install "$RUNNER_TEMP"/wheels/*.whl
|
||||||
|
"supermemory==${{ matrix.supermemory-version }}"
|
||||||
|
"pipecat-ai==${{ matrix.pipecat-version }}"
|
||||||
|
|
||||||
|
- name: Check dependency compatibility
|
||||||
|
run: python -m pip check
|
||||||
|
|
||||||
|
- name: Verify real Pipecat integration and run tests
|
||||||
|
run: >-
|
||||||
|
python -c "from importlib.metadata import version;
|
||||||
|
from pathlib import Path; import pipecat, pytest, supermemory_pipecat;
|
||||||
|
assert version('supermemory') == '${{ matrix.supermemory-version }}';
|
||||||
|
assert version('pipecat-ai') == '${{ matrix.pipecat-version }}';
|
||||||
|
assert 'site-packages' in Path(supermemory_pipecat.__file__).parts;
|
||||||
|
result = pytest.main(['-W', 'ignore::pytest.PytestAssertRewriteWarning', 'tests']);
|
||||||
|
raise SystemExit(result)"
|
||||||
79
.github/workflows/ci.yml
vendored
|
|
@ -26,8 +26,83 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run TypeScript type checking
|
- name: Detect SDK and playground changes
|
||||||
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
|
id: sdk-changes
|
||||||
|
run: |
|
||||||
|
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/tools; then
|
||||||
|
echo "tools=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "tools=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/ai-sdk; then
|
||||||
|
echo "ai_sdk=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "ai_sdk=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- apps/sdk-playground; then
|
||||||
|
echo "sdk_playground=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "sdk_playground=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Python for SDK Playground
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Setup uv for SDK Playground
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: true
|
||||||
|
working-directory: apps/sdk-playground/python
|
||||||
|
cache-dependency-glob: uv.lock
|
||||||
|
|
||||||
|
- name: Validate SDK Playground Python server
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
working-directory: apps/sdk-playground/python
|
||||||
|
run: |
|
||||||
|
uv sync --locked --python 3.12
|
||||||
|
.venv/bin/python -m py_compile server.py
|
||||||
|
.venv/bin/python -c "import server"
|
||||||
|
|
||||||
|
- name: Run Tools unit tests
|
||||||
|
if: steps.sdk-changes.outputs.tools == 'true'
|
||||||
|
run: bun run --cwd packages/tools test:unit
|
||||||
|
|
||||||
|
- name: Build Tools package
|
||||||
|
if: steps.sdk-changes.outputs.tools == 'true' || steps.sdk-changes.outputs.ai_sdk == 'true' || steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
run: bun run --cwd packages/tools build
|
||||||
|
|
||||||
|
- name: Run AI SDK type checking
|
||||||
|
if: steps.sdk-changes.outputs.tools == 'true' || steps.sdk-changes.outputs.ai_sdk == 'true' || steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
run: bun run --cwd packages/ai-sdk check-types
|
||||||
|
|
||||||
|
- name: Run AI SDK unit tests
|
||||||
|
if: steps.sdk-changes.outputs.ai_sdk == 'true'
|
||||||
|
run: bun run --cwd packages/ai-sdk test:unit
|
||||||
|
|
||||||
|
- name: Build AI SDK package
|
||||||
|
if: steps.sdk-changes.outputs.ai_sdk == 'true' || steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
run: bun run --cwd packages/ai-sdk build
|
||||||
|
|
||||||
|
- name: Run SDK Playground type checking
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
run: bun run --cwd apps/sdk-playground check-types:app
|
||||||
|
|
||||||
|
- name: Build SDK Playground
|
||||||
|
if: steps.sdk-changes.outputs.sdk_playground == 'true'
|
||||||
|
run: bun run --cwd apps/sdk-playground build:app
|
||||||
|
|
||||||
|
- name: Run Memory Graph type checking
|
||||||
|
run: bun run --cwd packages/memory-graph check-types
|
||||||
|
|
||||||
|
- name: Run Memory Graph unit tests
|
||||||
|
run: bun run --cwd packages/memory-graph test
|
||||||
|
|
||||||
- name: Run Biome CI (format & lint on changed files)
|
- name: Run Biome CI (format & lint on changed files)
|
||||||
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,22 @@ jobs:
|
||||||
working-directory: ./packages/agent-framework-python
|
working-directory: ./packages/agent-framework-python
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: pip install hatchling build
|
run: python -m pip install hatchling build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||||
with:
|
with:
|
||||||
packages-dir: packages/agent-framework-python/dist/
|
packages-dir: packages/agent-framework-python/dist/
|
||||||
|
|
|
||||||
49
.github/workflows/publish-ai-sdk.yml
vendored
|
|
@ -38,26 +38,65 @@ jobs:
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
working-directory: .
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Check if version changed
|
- name: Check if version changed
|
||||||
id: version-check
|
id: version-check
|
||||||
run: |
|
run: |
|
||||||
PACKAGE_NAME=$(jq -r '.name' package.json)
|
PACKAGE_NAME=$(jq -r '.name' package.json)
|
||||||
LOCAL_VERSION=$(jq -r '.version' package.json)
|
LOCAL_VERSION=$(jq -r '.version' package.json)
|
||||||
NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0")
|
if npm view "$PACKAGE_NAME@$LOCAL_VERSION" version >/dev/null 2>&1; then
|
||||||
if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then
|
|
||||||
echo "Version $LOCAL_VERSION already published, skipping."
|
echo "Version $LOCAL_VERSION already published, skipping."
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)"
|
echo "Publishing $LOCAL_VERSION."
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build
|
- name: Wait for the Tools dependency
|
||||||
|
if: steps.version-check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
TOOLS_SPEC=$(jq -r '.dependencies["@supermemory/tools"]' package.json)
|
||||||
|
TOOLS_VERSION=${TOOLS_SPEC#^}
|
||||||
|
TOOLS_VERSION=${TOOLS_VERSION#~}
|
||||||
|
|
||||||
|
if [[ ! "$TOOLS_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
|
||||||
|
echo "Unsupported @supermemory/tools dependency spec: $TOOLS_SPEC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for attempt in {1..20}; do
|
||||||
|
PUBLISHED_VERSION=$(npm view "@supermemory/tools@$TOOLS_VERSION" version 2>/dev/null || true)
|
||||||
|
if [ "$PUBLISHED_VERSION" = "$TOOLS_VERSION" ]; then
|
||||||
|
echo "@supermemory/tools@$TOOLS_VERSION is available on npm."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting for @supermemory/tools@$TOOLS_VERSION (attempt $attempt/20)."
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "@supermemory/tools@$TOOLS_VERSION was not published within five minutes." >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Build Tools dependency
|
||||||
|
if: steps.version-check.outputs.changed == 'true'
|
||||||
|
run: bun run --cwd ../tools build
|
||||||
|
|
||||||
|
- name: Build AI SDK package
|
||||||
if: steps.version-check.outputs.changed == 'true'
|
if: steps.version-check.outputs.changed == 'true'
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Verify packed artifact
|
||||||
|
if: steps.version-check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
npm pack --dry-run --json > "$RUNNER_TEMP/ai-sdk-pack.json"
|
||||||
|
jq -e '
|
||||||
|
(.[0].files | any(.path == "dist/index.js")) and
|
||||||
|
(.[0].files | any(.path == "dist/index.d.ts"))
|
||||||
|
' "$RUNNER_TEMP/ai-sdk-pack.json" >/dev/null
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
if: steps.version-check.outputs.changed == 'true'
|
if: steps.version-check.outputs.changed == 'true'
|
||||||
run: npm publish --access public --provenance
|
run: npm publish --access public --provenance
|
||||||
|
|
|
||||||
|
|
@ -23,20 +23,22 @@ jobs:
|
||||||
working-directory: ./packages/cartesia-sdk-python
|
working-directory: ./packages/cartesia-sdk-python
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: pip install hatchling build
|
run: python -m pip install hatchling build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||||
with:
|
with:
|
||||||
packages-dir: packages/cartesia-sdk-python/dist/
|
packages-dir: packages/cartesia-sdk-python/dist/
|
||||||
|
|
|
||||||
10
.github/workflows/publish-pipecat-sdk-python.yml
vendored
|
|
@ -23,20 +23,22 @@ jobs:
|
||||||
working-directory: ./packages/pipecat-sdk-python
|
working-directory: ./packages/pipecat-sdk-python
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: pip install hatchling build
|
run: python -m pip install hatchling build
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python -m build
|
||||||
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@release/v1
|
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||||
with:
|
with:
|
||||||
packages-dir: packages/pipecat-sdk-python/dist/
|
packages-dir: packages/pipecat-sdk-python/dist/
|
||||||
|
|
|
||||||
24
README.md
|
|
@ -7,7 +7,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>State-of-the-art memory and context engine for AI. And yes - you can use it as a company/personal brain.</strong>
|
<strong>State-of-the-art memory and context engine for AI.</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
@ -67,7 +67,7 @@ All of this is in our single memory structure and ontology.
|
||||||
|
|
||||||
<h3>🧑💻 I use AI tools</h3>
|
<h3>🧑💻 I use AI tools</h3>
|
||||||
|
|
||||||
Build your own personal supermemory by using our app. Builds **persistent memory graph across every conversation**.
|
Give Claude Code, Cursor, Codex and OpenCode **persistent memory across every conversation** with a plugin or the MCP server.
|
||||||
|
|
||||||
Your AI remembers your preferences, projects, past discussions — and gets smarter over time.
|
Your AI remembers your preferences, projects, past discussions — and gets smarter over time.
|
||||||
|
|
||||||
|
|
@ -107,21 +107,11 @@ curl -fsSL https://supermemory.ai/install | bash
|
||||||
|
|
||||||
## Give your AI memory
|
## Give your AI memory
|
||||||
|
|
||||||
The Supermemory App, browser extension, plugins and MCP server gives any compatible AI assistant persistent memory. One install, and your AI remembers you.
|
Plugins and the MCP server give any compatible AI assistant persistent memory. One install, and your AI remembers you.
|
||||||
|
|
||||||
### The app
|
|
||||||
|
|
||||||
You can use supermemory without any code, by using our consumer-facing app for free.
|
|
||||||
|
|
||||||
Start at https://app.supermemory.ai
|
|
||||||
|
|
||||||
<img width="1705" height="1030" alt="image" src="https://github.com/user-attachments/assets/5b43af30-b998-4585-8de6-f3e9a26d894a" />
|
|
||||||
|
|
||||||
It also comes with an agent embedded inside, which we call Nova.
|
|
||||||
|
|
||||||
### Supermemory Plugins
|
### Supermemory Plugins
|
||||||
|
|
||||||
Supermemory comes built with Plugins for Claude Code, OpenCode, OpenClaw, and Hermes.
|
Supermemory comes built with plugins for Claude Code, Cursor, Codex, OpenCode, OpenClaw, and Hermes.
|
||||||
|
|
||||||
<img width="844" height="484" alt="image" src="https://github.com/user-attachments/assets/ecb879a2-8652-495d-9228-f305a97ba603" />
|
<img width="844" height="484" alt="image" src="https://github.com/user-attachments/assets/ecb879a2-8652-495d-9228-f305a97ba603" />
|
||||||
|
|
||||||
|
|
@ -129,8 +119,10 @@ These plugins are implementations of the supermemory API, and they are open sour
|
||||||
|
|
||||||
You can find them here:
|
You can find them here:
|
||||||
|
|
||||||
- Openclaw plugin: https://github.com/supermemoryai/openclaw-supermemory
|
- Claude Code plugin: https://github.com/supermemoryai/claude-supermemory
|
||||||
- Claude code plugin: https://github.com/supermemoryai/claude-supermemory
|
- Cursor plugin: https://github.com/supermemoryai/cursor-supermemory
|
||||||
|
- Codex plugin: https://github.com/supermemoryai/codex-supermemory
|
||||||
|
- OpenClaw plugin: https://github.com/supermemoryai/openclaw-supermemory
|
||||||
- OpenCode plugin: https://github.com/supermemoryai/opencode-supermemory
|
- OpenCode plugin: https://github.com/supermemoryai/opencode-supermemory
|
||||||
- Hermes agent (Supermemory memory provider): https://github.com/NousResearch/hermes-agent
|
- Hermes agent (Supermemory memory provider): https://github.com/NousResearch/hermes-agent
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>面向 AI 的记忆与上下文引擎,业界领先。也可以把它当作公司或个人的「大脑」来用。</strong>
|
<strong>面向 AI 的记忆与上下文引擎,业界领先。</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|
@ -60,7 +60,7 @@ Supermemory 是为 AI 设计的记忆与上下文层。在 **[LongMemEval](https
|
||||||
|
|
||||||
<h3>🧑💻 我只是 AI 工具的用户</h3>
|
<h3>🧑💻 我只是 AI 工具的用户</h3>
|
||||||
|
|
||||||
直接用我们的应用,给自己搭一份专属的 supermemory。它会**在每次对话之间维护一张持久的记忆图谱**。
|
通过插件或 MCP 服务器,让 Claude Code、Cursor、Codex 和 OpenCode **在每次对话之间保持持久记忆**。
|
||||||
|
|
||||||
你的 AI 会记住你的偏好、项目、历史讨论——而且越用越聪明。
|
你的 AI 会记住你的偏好、项目、历史讨论——而且越用越聪明。
|
||||||
|
|
||||||
|
|
@ -85,28 +85,20 @@ Supermemory 是为 AI 设计的记忆与上下文层。在 **[LongMemEval](https
|
||||||
|
|
||||||
## 给你的 AI 装上记忆
|
## 给你的 AI 装上记忆
|
||||||
|
|
||||||
Supermemory 的应用、浏览器扩展、插件和 MCP 服务器,可以为任何兼容的 AI 助手提供持久记忆。装一次,AI 从此记住你。
|
插件和 MCP 服务器可以为任何兼容的 AI 助手提供持久记忆。装一次,AI 从此记住你。
|
||||||
|
|
||||||
### 应用
|
|
||||||
|
|
||||||
不用写代码,直接用我们面向消费者的应用——免费。
|
|
||||||
|
|
||||||
入口:https://app.supermemory.ai
|
|
||||||
|
|
||||||
<img width="1705" height="1030" alt="image" src="https://github.com/user-attachments/assets/5b43af30-b998-4585-8de6-f3e9a26d894a" />
|
|
||||||
|
|
||||||
应用里内置了一个 agent,我们叫它 Nova。
|
|
||||||
|
|
||||||
### Supermemory 插件
|
### Supermemory 插件
|
||||||
|
|
||||||
Supermemory 已经为 Claude Code、OpenCode、OpenClaw、Hermes 提供了开箱即用的插件。
|
Supermemory 已经为 Claude Code、Cursor、Codex、OpenCode、OpenClaw、Hermes 提供了开箱即用的插件。
|
||||||
|
|
||||||
<img width="844" height="484" alt="image" src="https://github.com/user-attachments/assets/ecb879a2-8652-495d-9228-f305a97ba603" />
|
<img width="844" height="484" alt="image" src="https://github.com/user-attachments/assets/ecb879a2-8652-495d-9228-f305a97ba603" />
|
||||||
|
|
||||||
这些插件本质上是 supermemory API 的实现,全部开源:
|
这些插件本质上是 supermemory API 的实现,全部开源:
|
||||||
|
|
||||||
- Openclaw 插件:https://github.com/supermemoryai/openclaw-supermemory
|
|
||||||
- Claude Code 插件:https://github.com/supermemoryai/claude-supermemory
|
- Claude Code 插件:https://github.com/supermemoryai/claude-supermemory
|
||||||
|
- Cursor 插件:https://github.com/supermemoryai/cursor-supermemory
|
||||||
|
- Codex 插件:https://github.com/supermemoryai/codex-supermemory
|
||||||
|
- OpenClaw 插件:https://github.com/supermemoryai/openclaw-supermemory
|
||||||
- OpenCode 插件:https://github.com/supermemoryai/opencode-supermemory
|
- OpenCode 插件:https://github.com/supermemoryai/opencode-supermemory
|
||||||
- Hermes agent(Supermemory 作为记忆 provider):https://github.com/NousResearch/hermes-agent
|
- Hermes agent(Supermemory 作为记忆 provider):https://github.com/NousResearch/hermes-agent
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# PostHog Configuration
|
|
||||||
WXT_POSTHOG_API_KEY=your_posthog_project_api_key_here
|
|
||||||
26
apps/browser-extension/.gitignore
vendored
|
|
@ -1,26 +0,0 @@
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
.output
|
|
||||||
stats.html
|
|
||||||
stats-*.json
|
|
||||||
.wxt
|
|
||||||
web-ext.config.ts
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
## supermemory Browser Extension
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
export function RightArrow({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="10"
|
|
||||||
height="11"
|
|
||||||
viewBox="0 0 10 11"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<title>Right arrow</title>
|
|
||||||
<path
|
|
||||||
d="M-1.26511e-05 5.82399V4.53599H7.81199L3.90599 0.895994L4.78799 -6.19888e-06L9.79999 4.77399V5.54399L4.78799 10.332L3.90599 9.43599L7.78399 5.82399H-1.26511e-05Z"
|
|
||||||
fill="#737373"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
import {
|
|
||||||
getDefaultProject,
|
|
||||||
saveMemory,
|
|
||||||
searchMemories,
|
|
||||||
fetchProjects,
|
|
||||||
} from "../utils/api"
|
|
||||||
import {
|
|
||||||
CONTAINER_TAGS,
|
|
||||||
MESSAGE_TYPES,
|
|
||||||
POSTHOG_EVENT_KEY,
|
|
||||||
} from "../utils/constants"
|
|
||||||
import { trackEvent } from "../utils/posthog"
|
|
||||||
import { captureTwitterTokens } from "../utils/twitter-auth"
|
|
||||||
import {
|
|
||||||
type TwitterImportConfig,
|
|
||||||
TwitterImporter,
|
|
||||||
} from "../utils/twitter-import"
|
|
||||||
import type {
|
|
||||||
ExtensionMessage,
|
|
||||||
MemoryData,
|
|
||||||
MemoryPayload,
|
|
||||||
} from "../utils/types"
|
|
||||||
|
|
||||||
const PLATFORM_LABELS: Record<string, string> = {
|
|
||||||
chatgpt: "ChatGPT",
|
|
||||||
claude: "Claude",
|
|
||||||
gemini: "Gemini",
|
|
||||||
t3: "T3 Chat",
|
|
||||||
twitter: "X / Twitter",
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePlatform(value?: string): string | undefined {
|
|
||||||
if (!value) return undefined
|
|
||||||
return value
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "_")
|
|
||||||
.replace(/^_+|_+$/g, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferPlatformFromActionSource(
|
|
||||||
actionSource: string,
|
|
||||||
): string | undefined {
|
|
||||||
const source = actionSource.toLowerCase()
|
|
||||||
if (source.includes("chatgpt")) return "chatgpt"
|
|
||||||
if (source.includes("claude")) return "claude"
|
|
||||||
if (source.includes("gemini")) return "gemini"
|
|
||||||
if (source.includes("t3")) return "t3"
|
|
||||||
if (source.includes("twitter") || source.includes("x_")) return "twitter"
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferPlatformFromUrl(url?: string): string | undefined {
|
|
||||||
if (!url) return undefined
|
|
||||||
try {
|
|
||||||
const hostname = new URL(url).hostname
|
|
||||||
if (hostname === "chatgpt.com" || hostname === "chat.openai.com") {
|
|
||||||
return "chatgpt"
|
|
||||||
}
|
|
||||||
if (hostname === "claude.ai") return "claude"
|
|
||||||
if (hostname === "gemini.google.com") return "gemini"
|
|
||||||
if (hostname === "t3.chat") return "t3"
|
|
||||||
if (hostname === "x.com" || hostname === "twitter.com") return "twitter"
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineBackground(() => {
|
|
||||||
let twitterImporter: TwitterImporter | null = null
|
|
||||||
|
|
||||||
browser.runtime.onInstalled.addListener(async (details) => {
|
|
||||||
if (details.reason === "install" || details.reason === "update") {
|
|
||||||
await trackEvent("extension_installed", {
|
|
||||||
reason: details.reason,
|
|
||||||
version: browser.runtime.getManifest().version,
|
|
||||||
})
|
|
||||||
browser.tabs.create({
|
|
||||||
url: browser.runtime.getURL("/welcome.html"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Intercept Twitter requests to capture authentication headers.
|
|
||||||
browser.webRequest.onBeforeSendHeaders.addListener(
|
|
||||||
(details) => {
|
|
||||||
captureTwitterTokens(details)
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
{ urls: ["*://x.com/*", "*://twitter.com/*"] },
|
|
||||||
["requestHeaders", "extraHeaders"],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send message to current active tab.
|
|
||||||
const sendMessageToCurrentTab = async (message: string) => {
|
|
||||||
const tabs = await browser.tabs.query({
|
|
||||||
active: true,
|
|
||||||
currentWindow: true,
|
|
||||||
})
|
|
||||||
if (tabs.length > 0 && tabs[0].id) {
|
|
||||||
await browser.tabs.sendMessage(tabs[0].id, {
|
|
||||||
type: MESSAGE_TYPES.IMPORT_UPDATE,
|
|
||||||
importedMessage: message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send import completion message
|
|
||||||
*/
|
|
||||||
const sendImportDoneMessage = async (totalImported: number) => {
|
|
||||||
const tabs = await browser.tabs.query({
|
|
||||||
active: true,
|
|
||||||
currentWindow: true,
|
|
||||||
})
|
|
||||||
if (tabs.length > 0 && tabs[0].id) {
|
|
||||||
await browser.tabs.sendMessage(tabs[0].id, {
|
|
||||||
type: MESSAGE_TYPES.IMPORT_DONE,
|
|
||||||
totalImported,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save memory to supermemory API
|
|
||||||
*/
|
|
||||||
const saveMemoryToSupermemory = async (
|
|
||||||
data: MemoryData,
|
|
||||||
actionSource: string,
|
|
||||||
): Promise<{ success: boolean; data?: unknown; error?: string }> => {
|
|
||||||
try {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
let content: string
|
|
||||||
if (data.content) {
|
|
||||||
content = data.content
|
|
||||||
} else if (data.highlightedText) {
|
|
||||||
content = `${data.highlightedText}\n\n${data?.url || ""}`
|
|
||||||
} else if (data.markdown) {
|
|
||||||
content = `${data.markdown}\n\n${data?.url || ""}`
|
|
||||||
} else if (data.html) {
|
|
||||||
content = `${data.html}\n\n${data?.url || ""}`
|
|
||||||
} else {
|
|
||||||
content = data?.url || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform =
|
|
||||||
normalizePlatform(data.sourcePlatform) ||
|
|
||||||
inferPlatformFromUrl(data.url) ||
|
|
||||||
inferPlatformFromActionSource(actionSource)
|
|
||||||
const platformLabel = platform
|
|
||||||
? data.sourcePlatformLabel || PLATFORM_LABELS[platform] || platform
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const metadata: MemoryPayload["metadata"] = {
|
|
||||||
sm_source: "consumer",
|
|
||||||
sm_origin: "browser_extension",
|
|
||||||
sm_origin_action: actionSource,
|
|
||||||
website_url: data.url,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform) {
|
|
||||||
metadata.sm_origin_platform = platform
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platformLabel) {
|
|
||||||
metadata.sm_origin_platform_label = platformLabel
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.sourceSurface) {
|
|
||||||
metadata.sm_origin_surface = data.sourceSurface
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.ogImage) {
|
|
||||||
metadata.website_og_image = data.ogImage
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.title) {
|
|
||||||
metadata.website_title = data.title
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: MemoryPayload = {
|
|
||||||
containerTags: [containerTag],
|
|
||||||
content,
|
|
||||||
metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = await saveMemory(payload)
|
|
||||||
|
|
||||||
await trackEvent(POSTHOG_EVENT_KEY.SAVE_MEMORY_ATTEMPTED, {
|
|
||||||
source: `${POSTHOG_EVENT_KEY.SOURCE}_${actionSource}`,
|
|
||||||
has_highlight: !!data.highlightedText,
|
|
||||||
url_domain: data.url ? new URL(data.url).hostname : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { success: true, data: responseData }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRelatedMemories = async (
|
|
||||||
data: string,
|
|
||||||
eventSource: string,
|
|
||||||
): Promise<{ success: boolean; data?: unknown; error?: string }> => {
|
|
||||||
try {
|
|
||||||
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 }>
|
|
||||||
}
|
|
||||||
const memories: string[] = []
|
|
||||||
response.results?.forEach((result, index) => {
|
|
||||||
memories.push(`${index + 1}. ${result.memory} \n`)
|
|
||||||
})
|
|
||||||
await trackEvent(eventSource)
|
|
||||||
return { success: true, data: memories }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle extension messages
|
|
||||||
*/
|
|
||||||
browser.runtime.onMessage.addListener(
|
|
||||||
(message: ExtensionMessage, _sender, sendResponse) => {
|
|
||||||
// Handle Twitter import request
|
|
||||||
if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) {
|
|
||||||
const importConfig: TwitterImportConfig = {
|
|
||||||
isFolderImport: message.isFolderImport,
|
|
||||||
bookmarkCollectionId: message.bookmarkCollectionId,
|
|
||||||
selectedProject: message.selectedProject,
|
|
||||||
onProgress: sendMessageToCurrentTab,
|
|
||||||
onComplete: sendImportDoneMessage,
|
|
||||||
onError: async (error: Error) => {
|
|
||||||
await sendMessageToCurrentTab(`Error: ${error.message}`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
twitterImporter = new TwitterImporter(importConfig)
|
|
||||||
twitterImporter.startImport().catch(console.error)
|
|
||||||
sendResponse({ success: true })
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle regular memory save request
|
|
||||||
if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const result = await saveMemoryToSupermemory(
|
|
||||||
message.data as MemoryData,
|
|
||||||
message.actionSource || "unknown",
|
|
||||||
)
|
|
||||||
sendResponse(result)
|
|
||||||
} catch (error) {
|
|
||||||
sendResponse({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === MESSAGE_TYPES.GET_RELATED_MEMORIES) {
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const result = await getRelatedMemories(
|
|
||||||
message.data as string,
|
|
||||||
message.actionSource || "unknown",
|
|
||||||
)
|
|
||||||
sendResponse(result)
|
|
||||||
} catch (error) {
|
|
||||||
sendResponse({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === MESSAGE_TYPES.CAPTURE_PROMPT) {
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const messageData = message.data as {
|
|
||||||
prompt: string
|
|
||||||
platform: string
|
|
||||||
source: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const memoryData: MemoryData = {
|
|
||||||
content: messageData.prompt,
|
|
||||||
url: messageData.source,
|
|
||||||
sourcePlatform: messageData.platform,
|
|
||||||
sourceSurface: "prompt_capture",
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await saveMemoryToSupermemory(
|
|
||||||
memoryData,
|
|
||||||
`prompt_capture_${messageData.platform}`,
|
|
||||||
)
|
|
||||||
sendResponse(result)
|
|
||||||
} catch (error) {
|
|
||||||
sendResponse({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.action === MESSAGE_TYPES.FETCH_PROJECTS) {
|
|
||||||
;(async () => {
|
|
||||||
try {
|
|
||||||
const projects = await fetchProjects()
|
|
||||||
sendResponse({ success: true, data: projects })
|
|
||||||
} catch (error) {
|
|
||||||
sendResponse({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
@ -1,783 +0,0 @@
|
||||||
import {
|
|
||||||
DOMAINS,
|
|
||||||
ELEMENT_IDS,
|
|
||||||
MESSAGE_TYPES,
|
|
||||||
POSTHOG_EVENT_KEY,
|
|
||||||
UI_CONFIG,
|
|
||||||
} from "../../utils/constants"
|
|
||||||
import {
|
|
||||||
autoSearchEnabled,
|
|
||||||
autoCapturePromptsEnabled,
|
|
||||||
} from "../../utils/storage"
|
|
||||||
import {
|
|
||||||
createChatGPTInputBarElement,
|
|
||||||
DOMUtils,
|
|
||||||
} from "../../utils/ui-components"
|
|
||||||
import {
|
|
||||||
acceptMemorySuggestion,
|
|
||||||
clearMemorySuggestion,
|
|
||||||
hasAcceptedSupermemoryContext,
|
|
||||||
serializeMemoriesForDataset,
|
|
||||||
setMemoryMarkerStatus,
|
|
||||||
showLoadingSuggestion,
|
|
||||||
showMarkerPopover,
|
|
||||||
showMemorySuggestion,
|
|
||||||
syncAcceptedSupermemoryState,
|
|
||||||
} from "./memory-suggestion"
|
|
||||||
|
|
||||||
let chatGPTDebounceTimeout: NodeJS.Timeout | null = null
|
|
||||||
let chatGPTRouteObserver: MutationObserver | null = null
|
|
||||||
let chatGPTUrlCheckInterval: NodeJS.Timeout | null = null
|
|
||||||
let chatGPTObserverThrottle: NodeJS.Timeout | null = null
|
|
||||||
const CHATGPT_DEBUG = false
|
|
||||||
const CHATGPT_LOG_PREFIX = "[supermemory:chatgpt]"
|
|
||||||
|
|
||||||
export function initializeChatGPT() {
|
|
||||||
debugChatGPT("initializeChatGPT called", {
|
|
||||||
host: window.location.hostname,
|
|
||||||
href: window.location.href,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
|
|
||||||
debugChatGPT("not on ChatGPT domain, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.body.hasAttribute("data-chatgpt-initialized")) {
|
|
||||||
debugChatGPT("already initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
addSupermemoryButtonToMemoriesDialog()
|
|
||||||
addSaveChatGPTElementBeforeComposerBtn()
|
|
||||||
setupChatGPTAutoFetch()
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
setupChatGPTPromptCapture()
|
|
||||||
|
|
||||||
setupChatGPTRouteChangeDetection()
|
|
||||||
|
|
||||||
document.body.setAttribute("data-chatgpt-initialized", "true")
|
|
||||||
debugChatGPT("initialized listeners")
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugChatGPT(message: string, data?: unknown) {
|
|
||||||
if (!CHATGPT_DEBUG) return
|
|
||||||
|
|
||||||
if (data === undefined) {
|
|
||||||
console.log(CHATGPT_LOG_PREFIX, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(CHATGPT_LOG_PREFIX, message, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupChatGPTRouteChangeDetection() {
|
|
||||||
if (chatGPTRouteObserver) {
|
|
||||||
chatGPTRouteObserver.disconnect()
|
|
||||||
}
|
|
||||||
if (chatGPTUrlCheckInterval) {
|
|
||||||
clearInterval(chatGPTUrlCheckInterval)
|
|
||||||
}
|
|
||||||
if (chatGPTObserverThrottle) {
|
|
||||||
clearTimeout(chatGPTObserverThrottle)
|
|
||||||
chatGPTObserverThrottle = null
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentUrl = window.location.href
|
|
||||||
|
|
||||||
const checkForRouteChange = () => {
|
|
||||||
if (window.location.href !== currentUrl) {
|
|
||||||
currentUrl = window.location.href
|
|
||||||
debugChatGPT("route changed, re-adding supermemory elements", currentUrl)
|
|
||||||
setTimeout(() => {
|
|
||||||
addSupermemoryButtonToMemoriesDialog()
|
|
||||||
addSaveChatGPTElementBeforeComposerBtn()
|
|
||||||
setupChatGPTAutoFetch()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
chatGPTUrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
|
||||||
|
|
||||||
chatGPTRouteObserver = new MutationObserver((mutations) => {
|
|
||||||
if (chatGPTObserverThrottle) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldRecheck = false
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
|
|
||||||
mutation.addedNodes.forEach((node) => {
|
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const element = node as Element
|
|
||||||
if (
|
|
||||||
element.querySelector?.("#prompt-textarea") ||
|
|
||||||
element.querySelector?.("button.composer-btn") ||
|
|
||||||
element.querySelector?.("button") ||
|
|
||||||
element.querySelector?.('[role="dialog"]') ||
|
|
||||||
element.matches?.("#prompt-textarea") ||
|
|
||||||
element.matches?.("button") ||
|
|
||||||
element.id === "prompt-textarea"
|
|
||||||
) {
|
|
||||||
shouldRecheck = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (shouldRecheck) {
|
|
||||||
chatGPTObserverThrottle = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
chatGPTObserverThrottle = null
|
|
||||||
debugChatGPT("DOM changed near composer, rechecking UI")
|
|
||||||
addSupermemoryButtonToMemoriesDialog()
|
|
||||||
addSaveChatGPTElementBeforeComposerBtn()
|
|
||||||
setupChatGPTAutoFetch()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in ChatGPT observer callback:", error)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
chatGPTRouteObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to set up ChatGPT route observer:", error)
|
|
||||||
if (chatGPTUrlCheckInterval) {
|
|
||||||
clearInterval(chatGPTUrlCheckInterval)
|
|
||||||
}
|
|
||||||
chatGPTUrlCheckInterval = setInterval(checkForRouteChange, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRelatedMemoriesForChatGPT(actionSource: string) {
|
|
||||||
try {
|
|
||||||
const isAutoSearch =
|
|
||||||
actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED
|
|
||||||
const userQuery =
|
|
||||||
document.getElementById("prompt-textarea")?.textContent || ""
|
|
||||||
|
|
||||||
const icon = document.querySelectorAll(
|
|
||||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
|
|
||||||
if (!iconElement) {
|
|
||||||
console.warn("ChatGPT icon element not found, cannot update feedback")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAutoSearch) {
|
|
||||||
const promptElement = document.getElementById("prompt-textarea")
|
|
||||||
if (promptElement) {
|
|
||||||
showLoadingSuggestion("chatgpt", promptElement)
|
|
||||||
}
|
|
||||||
setMemoryMarkerStatus(iconElement, "searching")
|
|
||||||
} else {
|
|
||||||
updateChatGPTIconFeedback("Searching memories...", iconElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error("Memory search timeout")),
|
|
||||||
UI_CONFIG.API_REQUEST_TIMEOUT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = await Promise.race([
|
|
||||||
browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.GET_RELATED_MEMORIES,
|
|
||||||
data: userQuery,
|
|
||||||
actionSource: actionSource,
|
|
||||||
}),
|
|
||||||
timeoutPromise,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (response?.success && response?.data) {
|
|
||||||
const promptElement = document.getElementById("prompt-textarea")
|
|
||||||
if (promptElement) {
|
|
||||||
const memoryText = showMemorySuggestion(
|
|
||||||
"chatgpt",
|
|
||||||
promptElement,
|
|
||||||
response.data,
|
|
||||||
)
|
|
||||||
debugChatGPT("memory suggestion rendered", {
|
|
||||||
memoryLength: memoryText.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
|
||||||
response.data,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isAutoSearch) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "found")
|
|
||||||
} else {
|
|
||||||
updateChatGPTIconFeedback("Included Memories", iconElement)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"ChatGPT prompt element not found after successful memory fetch",
|
|
||||||
)
|
|
||||||
if (isAutoSearch) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "found")
|
|
||||||
} else {
|
|
||||||
updateChatGPTIconFeedback("Memories found", iconElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn("No memories found or API response invalid")
|
|
||||||
if (isAutoSearch) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "none")
|
|
||||||
} else {
|
|
||||||
updateChatGPTIconFeedback("No memories found", iconElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting related memories:", error)
|
|
||||||
try {
|
|
||||||
const icon = document.querySelectorAll(
|
|
||||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
|
||||||
)[0] as HTMLElement
|
|
||||||
if (icon) {
|
|
||||||
if (
|
|
||||||
actionSource === POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED
|
|
||||||
) {
|
|
||||||
setMemoryMarkerStatus(icon, "error")
|
|
||||||
} else {
|
|
||||||
updateChatGPTIconFeedback("Error fetching memories", icon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (feedbackError) {
|
|
||||||
console.error("Failed to update error feedback:", feedbackError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSupermemoryButtonToMemoriesDialog() {
|
|
||||||
const dialogs = document.querySelectorAll('[role="dialog"]')
|
|
||||||
let memoriesDialog: HTMLElement | null = null
|
|
||||||
|
|
||||||
for (const dialog of dialogs) {
|
|
||||||
const headerText = dialog.querySelector("h2")
|
|
||||||
if (headerText?.textContent?.includes("Saved memories")) {
|
|
||||||
memoriesDialog = dialog as HTMLElement
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!memoriesDialog) return
|
|
||||||
|
|
||||||
if (memoriesDialog.querySelector("#supermemory-save-button")) return
|
|
||||||
|
|
||||||
const deleteAllContainer = memoriesDialog.querySelector(
|
|
||||||
".flex.items-center.gap-0\\.5",
|
|
||||||
)
|
|
||||||
if (!deleteAllContainer) return
|
|
||||||
|
|
||||||
const supermemoryButton = document.createElement("button")
|
|
||||||
supermemoryButton.id = "supermemory-save-button"
|
|
||||||
supermemoryButton.className = "btn relative btn-primary-outline mr-2"
|
|
||||||
|
|
||||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
|
||||||
|
|
||||||
supermemoryButton.innerHTML = `
|
|
||||||
<div class="flex items-center justify-center gap-2">
|
|
||||||
<img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
|
|
||||||
Save to supermemory
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
supermemoryButton.style.cssText = `
|
|
||||||
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;
|
|
||||||
margin-right: 8px !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
`
|
|
||||||
|
|
||||||
supermemoryButton.addEventListener("mouseenter", () => {
|
|
||||||
supermemoryButton.style.backgroundColor = "#2B2E33"
|
|
||||||
})
|
|
||||||
|
|
||||||
supermemoryButton.addEventListener("mouseleave", () => {
|
|
||||||
supermemoryButton.style.backgroundColor = "#1C2026"
|
|
||||||
})
|
|
||||||
|
|
||||||
supermemoryButton.addEventListener("click", async () => {
|
|
||||||
await saveMemoriesToSupermemory()
|
|
||||||
})
|
|
||||||
|
|
||||||
deleteAllContainer.insertBefore(
|
|
||||||
supermemoryButton,
|
|
||||||
deleteAllContainer.firstChild,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveMemoriesToSupermemory() {
|
|
||||||
try {
|
|
||||||
DOMUtils.showToast("loading")
|
|
||||||
|
|
||||||
const memoriesTable = document.querySelector('[role="dialog"] table tbody')
|
|
||||||
if (!memoriesTable) {
|
|
||||||
DOMUtils.showToast("error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!memoriesTable.textContent) {
|
|
||||||
DOMUtils.showToast("error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedContent = `Memories from ChatGPT:\n\n${memoriesTable.textContent}`
|
|
||||||
|
|
||||||
const response = await browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
|
||||||
data: {
|
|
||||||
html: combinedContent,
|
|
||||||
sourcePlatform: "chatgpt",
|
|
||||||
sourceSurface: "memories_dialog",
|
|
||||||
url: window.location.href,
|
|
||||||
},
|
|
||||||
actionSource: "chatgpt_memories_dialog",
|
|
||||||
})
|
|
||||||
|
|
||||||
debugChatGPT("memory dialog saved", {
|
|
||||||
success: response.success,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
DOMUtils.showToast("success")
|
|
||||||
} else {
|
|
||||||
DOMUtils.showToast("error")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving memories to supermemory:", error)
|
|
||||||
DOMUtils.showToast("error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateChatGPTIconFeedback(
|
|
||||||
message: string,
|
|
||||||
iconElement: HTMLElement,
|
|
||||||
resetAfter = 0,
|
|
||||||
) {
|
|
||||||
const memories = iconElement.dataset.memoriesData
|
|
||||||
const fallbackReset =
|
|
||||||
resetAfter || (message === "Included Memories" ? 0 : 2200)
|
|
||||||
|
|
||||||
if (message === "Included Memories" || message === "Memories found") {
|
|
||||||
setMemoryMarkerStatus(iconElement, "found")
|
|
||||||
showMarkerPopover(iconElement, "Included Memories", memories)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.toLowerCase().includes("searching")) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "searching")
|
|
||||||
showMarkerPopover(iconElement, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setMemoryMarkerStatus(
|
|
||||||
iconElement,
|
|
||||||
message.toLowerCase().includes("error") ? "error" : "none",
|
|
||||||
)
|
|
||||||
showMarkerPopover(iconElement, message, undefined, fallbackReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSaveChatGPTElementBeforeComposerBtn() {
|
|
||||||
const promptInput = getChatGPTPromptInput()
|
|
||||||
if (!promptInput) {
|
|
||||||
debugChatGPT("prompt input not found", getChatGPTDomSnapshot())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const composer = findChatGPTComposerRoot(promptInput)
|
|
||||||
if (!composer?.querySelector) {
|
|
||||||
debugChatGPT("composer root not found", describeElement(promptInput))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingMarkers = Array.from(
|
|
||||||
document.querySelectorAll(
|
|
||||||
`[id*="${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer"]`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (existingMarkers.length > 1) {
|
|
||||||
debugChatGPT("removed duplicate markers", existingMarkers.length)
|
|
||||||
for (const marker of existingMarkers) {
|
|
||||||
marker.remove()
|
|
||||||
}
|
|
||||||
} else if (existingMarkers.length === 1) {
|
|
||||||
debugChatGPT("marker already exists")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttons = findChatGPTComposerButtons(promptInput, composer)
|
|
||||||
debugChatGPT("candidate ChatGPT buttons", {
|
|
||||||
input: describeElement(promptInput),
|
|
||||||
composer: describeElement(composer),
|
|
||||||
buttons: buttons.map((button) => ({
|
|
||||||
label: buttonLabel(button),
|
|
||||||
element: describeElement(button),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
const micButton = buttons.find((button) => isChatGPTMicButton(button))
|
|
||||||
const voiceButton = buttons.find((button) => isChatGPTVoiceButton(button))
|
|
||||||
const sendButton = buttons.find((button) => isChatGPTSendButton(button))
|
|
||||||
const anchorButton =
|
|
||||||
micButton || voiceButton || sendButton || buttons[buttons.length - 1]
|
|
||||||
const anchorSlot = findChatGPTButtonSlot(anchorButton, composer)
|
|
||||||
const speechContainer = composer.querySelector(
|
|
||||||
'[data-testid="composer-speech-button-container"]',
|
|
||||||
) as HTMLElement | null
|
|
||||||
const targetContainer =
|
|
||||||
anchorSlot?.parentElement ||
|
|
||||||
speechContainer?.parentElement ||
|
|
||||||
promptInput.parentElement
|
|
||||||
|
|
||||||
if (!targetContainer) {
|
|
||||||
debugChatGPT("could not find insertion target", {
|
|
||||||
anchor: anchorButton ? describeElement(anchorButton) : null,
|
|
||||||
input: describeElement(promptInput),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveChatGPTElement = createChatGPTInputBarElement(async () => {
|
|
||||||
await getRelatedMemoriesForChatGPT(
|
|
||||||
POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_SEARCHED,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
|
||||||
|
|
||||||
if (anchorSlot?.parentElement === targetContainer) {
|
|
||||||
targetContainer.insertBefore(saveChatGPTElement, anchorSlot)
|
|
||||||
debugChatGPT("inserted marker before anchor button", {
|
|
||||||
anchorLabel: anchorButton ? buttonLabel(anchorButton) : null,
|
|
||||||
anchorSlot: describeElement(anchorSlot),
|
|
||||||
target: describeElement(targetContainer),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
targetContainer.appendChild(saveChatGPTElement)
|
|
||||||
debugChatGPT("inserted marker into fallback target", {
|
|
||||||
target: describeElement(targetContainer),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setupChatGPTAutoFetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChatGPTPromptInput(): HTMLElement | null {
|
|
||||||
return document.querySelector(
|
|
||||||
'#prompt-textarea, [data-testid="prompt-textarea"], div[contenteditable="true"]',
|
|
||||||
) as HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function findChatGPTComposerRoot(input: HTMLElement): HTMLElement {
|
|
||||||
const form = input.closest("form") as HTMLElement | null
|
|
||||||
if (form) return form
|
|
||||||
|
|
||||||
let current: HTMLElement | null = input
|
|
||||||
for (let depth = 0; current && depth < 8; depth += 1) {
|
|
||||||
if (current.querySelectorAll("button").length >= 2) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
current = current.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
return input.parentElement || document.body
|
|
||||||
}
|
|
||||||
|
|
||||||
function findChatGPTComposerButtons(
|
|
||||||
input: HTMLElement,
|
|
||||||
composer: HTMLElement,
|
|
||||||
): HTMLButtonElement[] {
|
|
||||||
const composerButtons = Array.from(composer.querySelectorAll("button"))
|
|
||||||
if (composerButtons.length > 0) {
|
|
||||||
return composerButtons
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputRect = input.getBoundingClientRect()
|
|
||||||
const allButtons = Array.from(document.querySelectorAll("button"))
|
|
||||||
|
|
||||||
return allButtons.filter((button) => {
|
|
||||||
const rect = button.getBoundingClientRect()
|
|
||||||
const verticallyNear =
|
|
||||||
Math.abs(
|
|
||||||
rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2),
|
|
||||||
) < 120
|
|
||||||
const horizontallyNear =
|
|
||||||
rect.left > inputRect.left - 80 && rect.left < inputRect.right + 260
|
|
||||||
|
|
||||||
return verticallyNear && horizontallyNear
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function buttonLabel(button: HTMLButtonElement): string {
|
|
||||||
return [
|
|
||||||
button.id,
|
|
||||||
button.getAttribute("aria-label"),
|
|
||||||
button.getAttribute("title"),
|
|
||||||
button.getAttribute("data-testid"),
|
|
||||||
button.getAttribute("data-test-id"),
|
|
||||||
button.textContent,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChatGPTMicButton(button: HTMLButtonElement): boolean {
|
|
||||||
return /mic|microphone|dictate/i.test(buttonLabel(button))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChatGPTVoiceButton(button: HTMLButtonElement): boolean {
|
|
||||||
return /voice|audio|speech/i.test(buttonLabel(button))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChatGPTSendButton(button: HTMLButtonElement): boolean {
|
|
||||||
const label = buttonLabel(button)
|
|
||||||
return /composer-submit-button|send|submit/i.test(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
function findChatGPTButtonSlot(
|
|
||||||
button: HTMLButtonElement | undefined,
|
|
||||||
composer: HTMLElement,
|
|
||||||
): HTMLElement | null {
|
|
||||||
if (!button) return null
|
|
||||||
|
|
||||||
let current: HTMLElement | null = button
|
|
||||||
while (current?.parentElement && current.parentElement !== composer) {
|
|
||||||
const parent: HTMLElement = current.parentElement
|
|
||||||
const parentStyle = window.getComputedStyle(parent)
|
|
||||||
const hasSiblingControls = parent.children.length > 1
|
|
||||||
const isRow =
|
|
||||||
parentStyle.display.includes("flex") &&
|
|
||||||
parentStyle.flexDirection !== "column"
|
|
||||||
|
|
||||||
if (hasSiblingControls && isRow) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
current = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
return current || button
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeElement(element: Element | null): string | null {
|
|
||||||
if (!element) return null
|
|
||||||
|
|
||||||
const parts = [element.tagName.toLowerCase()]
|
|
||||||
if (element.id) parts.push(`#${element.id}`)
|
|
||||||
if (element.className && typeof element.className === "string") {
|
|
||||||
parts.push(
|
|
||||||
`.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) {
|
|
||||||
const value = element.getAttribute(attr)
|
|
||||||
if (value) parts.push(`[${attr}="${value}"]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChatGPTDomSnapshot() {
|
|
||||||
return {
|
|
||||||
promptTextareas: document.querySelectorAll("#prompt-textarea").length,
|
|
||||||
contenteditables: document.querySelectorAll('[contenteditable="true"]')
|
|
||||||
.length,
|
|
||||||
textareas: document.querySelectorAll("textarea").length,
|
|
||||||
buttons: document.querySelectorAll("button").length,
|
|
||||||
composerButtons: document.querySelectorAll("button.composer-btn").length,
|
|
||||||
speechContainers: document.querySelectorAll(
|
|
||||||
'[data-testid="composer-speech-button-container"]',
|
|
||||||
).length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupChatGPTAutoFetch() {
|
|
||||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
|
||||||
|
|
||||||
if (!autoSearch) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const promptTextarea = document.getElementById("prompt-textarea")
|
|
||||||
if (
|
|
||||||
!promptTextarea ||
|
|
||||||
promptTextarea.hasAttribute("data-supermemory-auto-fetch")
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
promptTextarea.setAttribute("data-supermemory-auto-fetch", "true")
|
|
||||||
|
|
||||||
const handleInput = () => {
|
|
||||||
const content = promptTextarea.textContent?.trim() || ""
|
|
||||||
syncAcceptedSupermemoryState(promptTextarea)
|
|
||||||
|
|
||||||
if (content.length === 0) {
|
|
||||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
|
||||||
document
|
|
||||||
.querySelectorAll(
|
|
||||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
|
||||||
)
|
|
||||||
.forEach((icon) => {
|
|
||||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chatGPTDebounceTimeout) {
|
|
||||||
clearTimeout(chatGPTDebounceTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
chatGPTDebounceTimeout = setTimeout(async () => {
|
|
||||||
if (hasAcceptedSupermemoryContext(promptTextarea)) {
|
|
||||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length > 2) {
|
|
||||||
await getRelatedMemoriesForChatGPT(
|
|
||||||
POSTHOG_EVENT_KEY.CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED,
|
|
||||||
)
|
|
||||||
} else if (content.length === 0) {
|
|
||||||
const icons = document.querySelectorAll(
|
|
||||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
|
||||||
)
|
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
setMemoryMarkerStatus(iconElement, "neutral")
|
|
||||||
if (iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (promptTextarea.dataset.supermemories) {
|
|
||||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
promptTextarea.addEventListener("input", handleInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupChatGPTPromptCapture() {
|
|
||||||
if (document.body.hasAttribute("data-chatgpt-prompt-capture-setup")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
document.body.setAttribute("data-chatgpt-prompt-capture-setup", "true")
|
|
||||||
|
|
||||||
const capturePromptContent = async (source: string) => {
|
|
||||||
const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false
|
|
||||||
|
|
||||||
if (!autoCapture) {
|
|
||||||
debugChatGPT("auto prompt capture disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const promptTextarea = document.getElementById("prompt-textarea")
|
|
||||||
|
|
||||||
let promptContent = ""
|
|
||||||
if (promptTextarea) {
|
|
||||||
promptContent = promptTextarea.textContent || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if (promptTextarea && promptContent.trim()) {
|
|
||||||
debugChatGPT("prompt submitted", {
|
|
||||||
source,
|
|
||||||
promptLength: promptContent.length,
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
await browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.CAPTURE_PROMPT,
|
|
||||||
data: {
|
|
||||||
prompt: promptContent,
|
|
||||||
platform: "chatgpt",
|
|
||||||
source: window.location.href,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sending ChatGPT prompt to background:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons = document.querySelectorAll(
|
|
||||||
'[id*="sm-chatgpt-input-bar-element-before-composer"]',
|
|
||||||
)
|
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
if (iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (promptTextarea?.dataset.supermemories) {
|
|
||||||
clearMemorySuggestion("chatgpt", promptTextarea)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"click",
|
|
||||||
async (event) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
if (
|
|
||||||
target.id === "composer-submit-button" ||
|
|
||||||
target.closest("#composer-submit-button")
|
|
||||||
) {
|
|
||||||
await capturePromptContent("button click")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"keydown",
|
|
||||||
async (event) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
|
|
||||||
if (
|
|
||||||
(target.id === "prompt-textarea" ||
|
|
||||||
target.closest("#prompt-textarea")) &&
|
|
||||||
acceptMemorySuggestion(
|
|
||||||
event,
|
|
||||||
"chatgpt",
|
|
||||||
document.getElementById("prompt-textarea"),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
target.id === "prompt-textarea" &&
|
|
||||||
event.key === "Enter" &&
|
|
||||||
!event.shiftKey
|
|
||||||
) {
|
|
||||||
await capturePromptContent("Enter key")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,909 +0,0 @@
|
||||||
import {
|
|
||||||
DOMAINS,
|
|
||||||
ELEMENT_IDS,
|
|
||||||
MESSAGE_TYPES,
|
|
||||||
POSTHOG_EVENT_KEY,
|
|
||||||
UI_CONFIG,
|
|
||||||
} from "../../utils/constants"
|
|
||||||
import {
|
|
||||||
autoSearchEnabled,
|
|
||||||
autoCapturePromptsEnabled,
|
|
||||||
} from "../../utils/storage"
|
|
||||||
import {
|
|
||||||
createClaudeInputBarElement,
|
|
||||||
DOMUtils,
|
|
||||||
} from "../../utils/ui-components"
|
|
||||||
import {
|
|
||||||
acceptMemorySuggestion,
|
|
||||||
clearMemorySuggestion,
|
|
||||||
hasAcceptedSupermemoryContext,
|
|
||||||
serializeMemoriesForDataset,
|
|
||||||
setMemoryMarkerStatus,
|
|
||||||
showLoadingSuggestion,
|
|
||||||
showMarkerPopover,
|
|
||||||
showMemorySuggestion,
|
|
||||||
syncAcceptedSupermemoryState,
|
|
||||||
} from "./memory-suggestion"
|
|
||||||
|
|
||||||
let claudeDebounceTimeout: NodeJS.Timeout | null = null
|
|
||||||
let claudeRouteObserver: MutationObserver | null = null
|
|
||||||
let claudeUrlCheckInterval: NodeJS.Timeout | null = null
|
|
||||||
let claudeObserverThrottle: NodeJS.Timeout | null = null
|
|
||||||
const CLAUDE_DEBUG = false
|
|
||||||
const CLAUDE_LOG_PREFIX = "[supermemory:claude]"
|
|
||||||
|
|
||||||
export function initializeClaude() {
|
|
||||||
debugClaude("initializeClaude called", {
|
|
||||||
host: window.location.hostname,
|
|
||||||
href: window.location.href,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!DOMUtils.isOnDomain(DOMAINS.CLAUDE)) {
|
|
||||||
debugClaude("not on Claude domain, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.body.hasAttribute("data-claude-initialized")) {
|
|
||||||
debugClaude("already initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
addSupermemoryButtonToClaudeMemoryDialog()
|
|
||||||
addSupermemoryIconToClaudeInput()
|
|
||||||
setupClaudeAutoFetch()
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
setupClaudePromptCapture()
|
|
||||||
|
|
||||||
setupClaudeRouteChangeDetection()
|
|
||||||
|
|
||||||
document.body.setAttribute("data-claude-initialized", "true")
|
|
||||||
debugClaude("initialized listeners")
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugClaude(message: string, data?: unknown) {
|
|
||||||
if (!CLAUDE_DEBUG) return
|
|
||||||
|
|
||||||
if (data === undefined) {
|
|
||||||
console.log(CLAUDE_LOG_PREFIX, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(CLAUDE_LOG_PREFIX, message, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupClaudeRouteChangeDetection() {
|
|
||||||
if (claudeRouteObserver) {
|
|
||||||
claudeRouteObserver.disconnect()
|
|
||||||
}
|
|
||||||
if (claudeUrlCheckInterval) {
|
|
||||||
clearInterval(claudeUrlCheckInterval)
|
|
||||||
}
|
|
||||||
if (claudeObserverThrottle) {
|
|
||||||
clearTimeout(claudeObserverThrottle)
|
|
||||||
claudeObserverThrottle = null
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentUrl = window.location.href
|
|
||||||
|
|
||||||
const checkForRouteChange = () => {
|
|
||||||
if (window.location.href !== currentUrl) {
|
|
||||||
currentUrl = window.location.href
|
|
||||||
debugClaude("route changed, re-adding supermemory icon", currentUrl)
|
|
||||||
setTimeout(() => {
|
|
||||||
addSupermemoryButtonToClaudeMemoryDialog()
|
|
||||||
addSupermemoryIconToClaudeInput()
|
|
||||||
setupClaudeAutoFetch()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeUrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
|
||||||
|
|
||||||
claudeRouteObserver = new MutationObserver((mutations) => {
|
|
||||||
if (claudeObserverThrottle) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldRecheck = false
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
|
|
||||||
mutation.addedNodes.forEach((node) => {
|
|
||||||
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.querySelector?.("button") ||
|
|
||||||
element.matches?.('[role="dialog"]') ||
|
|
||||||
element.matches?.('div[contenteditable="true"]') ||
|
|
||||||
element.matches?.("textarea") ||
|
|
||||||
element.matches?.("button") ||
|
|
||||||
element.textContent?.includes("Manage memory")
|
|
||||||
) {
|
|
||||||
shouldRecheck = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (shouldRecheck) {
|
|
||||||
claudeObserverThrottle = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
claudeObserverThrottle = null
|
|
||||||
debugClaude("DOM changed near composer, rechecking UI")
|
|
||||||
addSupermemoryButtonToClaudeMemoryDialog()
|
|
||||||
addSupermemoryIconToClaudeInput()
|
|
||||||
setupClaudeAutoFetch()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in Claude observer callback:", error)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
claudeRouteObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to set up Claude route observer:", error)
|
|
||||||
if (claudeUrlCheckInterval) {
|
|
||||||
clearInterval(claudeUrlCheckInterval)
|
|
||||||
}
|
|
||||||
claudeUrlCheckInterval = setInterval(checkForRouteChange, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSupermemoryIconToClaudeInput() {
|
|
||||||
const input = getClaudePromptInput()
|
|
||||||
if (!input) {
|
|
||||||
debugClaude("prompt input not found", getClaudeDomSnapshot())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const composer = findComposerRoot(input)
|
|
||||||
if (!composer?.querySelector) {
|
|
||||||
debugClaude("composer root not found", describeElement(input))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingMarkers = Array.from(
|
|
||||||
document.querySelectorAll(
|
|
||||||
`[id*="${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}"]`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (existingMarkers.length > 1) {
|
|
||||||
debugClaude("removed duplicate markers", existingMarkers.length)
|
|
||||||
for (const marker of existingMarkers) {
|
|
||||||
marker.remove()
|
|
||||||
}
|
|
||||||
} else if (existingMarkers.length === 1) {
|
|
||||||
debugClaude("marker already exists")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttons = findClaudeComposerButtons(input, composer)
|
|
||||||
debugClaude("candidate Claude buttons", {
|
|
||||||
input: describeElement(input),
|
|
||||||
composer: describeElement(composer),
|
|
||||||
buttons: buttons.map((button) => ({
|
|
||||||
label: buttonLabel(button),
|
|
||||||
element: describeElement(button),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
const micButton = buttons.find((button) => isClaudeMicButton(button))
|
|
||||||
const voiceButton = buttons.find((button) => isClaudeVoiceButton(button))
|
|
||||||
const sendButton = buttons.find((button) => isClaudeSendButton(button))
|
|
||||||
const anchorButton =
|
|
||||||
micButton || voiceButton || sendButton || buttons[buttons.length - 1]
|
|
||||||
const anchorSlot = findClaudeButtonSlot(anchorButton, composer)
|
|
||||||
const targetContainer = anchorSlot?.parentElement || input.parentElement
|
|
||||||
|
|
||||||
if (!targetContainer) {
|
|
||||||
debugClaude("could not find insertion target", {
|
|
||||||
anchor: anchorButton ? describeElement(anchorButton) : null,
|
|
||||||
input: describeElement(input),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const supermemoryIcon = createClaudeInputBarElement(async () => {
|
|
||||||
await getRelatedMemoriesForClaude(
|
|
||||||
POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_SEARCHED,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
supermemoryIcon.id = `${ELEMENT_IDS.CLAUDE_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
|
||||||
|
|
||||||
if (anchorSlot?.parentElement === targetContainer) {
|
|
||||||
targetContainer.insertBefore(supermemoryIcon, anchorSlot)
|
|
||||||
debugClaude("inserted marker before anchor button", {
|
|
||||||
anchorLabel: anchorButton ? buttonLabel(anchorButton) : null,
|
|
||||||
anchorSlot: describeElement(anchorSlot),
|
|
||||||
target: describeElement(targetContainer),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetContainer.appendChild(supermemoryIcon)
|
|
||||||
debugClaude("inserted marker into fallback target", {
|
|
||||||
target: describeElement(targetContainer),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClaudePromptInput(): HTMLElement | null {
|
|
||||||
return document.querySelector(
|
|
||||||
'.ProseMirror[contenteditable="true"], div[contenteditable="true"], textarea',
|
|
||||||
) as HTMLElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function findComposerRoot(input: HTMLElement): HTMLElement {
|
|
||||||
return (
|
|
||||||
(input.closest("form") as HTMLElement | null) ||
|
|
||||||
(input.closest('[data-testid*="composer"]') as HTMLElement | null) ||
|
|
||||||
(input.closest('[class*="composer"]') as HTMLElement | null) ||
|
|
||||||
(input.closest(".relative") as HTMLElement | null) ||
|
|
||||||
input.parentElement ||
|
|
||||||
document.body
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buttonLabel(button: HTMLButtonElement): string {
|
|
||||||
return [
|
|
||||||
button.getAttribute("aria-label"),
|
|
||||||
button.getAttribute("title"),
|
|
||||||
button.getAttribute("data-testid"),
|
|
||||||
button.getAttribute("data-test-id"),
|
|
||||||
button.textContent,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function findClaudeComposerButtons(
|
|
||||||
input: HTMLElement,
|
|
||||||
composer: HTMLElement,
|
|
||||||
): HTMLButtonElement[] {
|
|
||||||
const composerButtons = Array.from(composer.querySelectorAll("button"))
|
|
||||||
if (composerButtons.length > 0) {
|
|
||||||
return composerButtons
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputRect = input.getBoundingClientRect()
|
|
||||||
const allButtons = Array.from(document.querySelectorAll("button"))
|
|
||||||
|
|
||||||
return allButtons.filter((button) => {
|
|
||||||
const rect = button.getBoundingClientRect()
|
|
||||||
const verticallyNear =
|
|
||||||
Math.abs(
|
|
||||||
rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2),
|
|
||||||
) < 120
|
|
||||||
const horizontallyNear =
|
|
||||||
rect.left > inputRect.left - 80 && rect.left < inputRect.right + 260
|
|
||||||
|
|
||||||
return verticallyNear && horizontallyNear
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClaudeMicButton(button: HTMLButtonElement): boolean {
|
|
||||||
return /mic|microphone|dictate/i.test(buttonLabel(button))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClaudeVoiceButton(button: HTMLButtonElement): boolean {
|
|
||||||
return /voice|audio|speech/i.test(buttonLabel(button))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClaudeSendButton(button: HTMLButtonElement): boolean {
|
|
||||||
return /send|submit/i.test(buttonLabel(button))
|
|
||||||
}
|
|
||||||
|
|
||||||
function findClaudeButtonSlot(
|
|
||||||
button: HTMLButtonElement | undefined,
|
|
||||||
composer: HTMLElement,
|
|
||||||
): HTMLElement | null {
|
|
||||||
if (!button) return null
|
|
||||||
|
|
||||||
let current: HTMLElement | null = button
|
|
||||||
while (current?.parentElement && current.parentElement !== composer) {
|
|
||||||
const parent: HTMLElement = current.parentElement
|
|
||||||
const parentStyle = window.getComputedStyle(parent)
|
|
||||||
const hasSiblingControls = parent.children.length > 1
|
|
||||||
const isRow =
|
|
||||||
parentStyle.display.includes("flex") &&
|
|
||||||
parentStyle.flexDirection !== "column"
|
|
||||||
|
|
||||||
if (hasSiblingControls && isRow) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
current = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
return current || button
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeElement(element: Element | null): string | null {
|
|
||||||
if (!element) return null
|
|
||||||
|
|
||||||
const parts = [element.tagName.toLowerCase()]
|
|
||||||
if (element.id) parts.push(`#${element.id}`)
|
|
||||||
if (element.className && typeof element.className === "string") {
|
|
||||||
parts.push(
|
|
||||||
`.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) {
|
|
||||||
const value = element.getAttribute(attr)
|
|
||||||
if (value) parts.push(`[${attr}="${value}"]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClaudeDomSnapshot() {
|
|
||||||
return {
|
|
||||||
proseMirrors: document.querySelectorAll(".ProseMirror").length,
|
|
||||||
contenteditables: document.querySelectorAll('[contenteditable="true"]')
|
|
||||||
.length,
|
|
||||||
textareas: document.querySelectorAll("textarea").length,
|
|
||||||
buttons: document.querySelectorAll("button").length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRelatedMemoriesForClaude(actionSource: string) {
|
|
||||||
try {
|
|
||||||
const isAutoSearch =
|
|
||||||
actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED
|
|
||||||
let userQuery = ""
|
|
||||||
|
|
||||||
const supermemoryContainer = document.querySelector(
|
|
||||||
'[data-supermemory-icon-added="true"]',
|
|
||||||
)
|
|
||||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
|
||||||
const pTag =
|
|
||||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
|
||||||
"p",
|
|
||||||
)
|
|
||||||
userQuery = pTag?.innerText || pTag?.textContent || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userQuery.trim()) {
|
|
||||||
const textareaElement = document.querySelector(
|
|
||||||
'div[contenteditable="true"]',
|
|
||||||
) as HTMLElement
|
|
||||||
userQuery =
|
|
||||||
textareaElement?.innerText || textareaElement?.textContent || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userQuery.trim()) {
|
|
||||||
const inputElements = document.querySelectorAll(
|
|
||||||
'div[contenteditable="true"], textarea, input[type="text"]',
|
|
||||||
)
|
|
||||||
for (const element of inputElements) {
|
|
||||||
const text =
|
|
||||||
(element as HTMLElement).innerText ||
|
|
||||||
(element as HTMLInputElement).value
|
|
||||||
if (text?.trim()) {
|
|
||||||
userQuery = text.trim()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debugClaude("query extracted", {
|
|
||||||
queryLength: userQuery.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!userQuery.trim()) {
|
|
||||||
debugClaude("memory search skipped because query is empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = document.querySelector('[id*="sm-claude-input-bar-element"]')
|
|
||||||
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
|
|
||||||
if (!iconElement) {
|
|
||||||
console.warn("Claude icon element not found, cannot update feedback")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAutoSearch) {
|
|
||||||
const input = getClaudePromptInput()
|
|
||||||
if (input) {
|
|
||||||
showLoadingSuggestion("claude", input)
|
|
||||||
}
|
|
||||||
setMemoryMarkerStatus(iconElement, "searching")
|
|
||||||
} else {
|
|
||||||
updateClaudeIconFeedback("Searching memories...", iconElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error("Memory search timeout")),
|
|
||||||
UI_CONFIG.API_REQUEST_TIMEOUT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = await Promise.race([
|
|
||||||
browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.GET_RELATED_MEMORIES,
|
|
||||||
data: userQuery,
|
|
||||||
actionSource: actionSource,
|
|
||||||
}),
|
|
||||||
timeoutPromise,
|
|
||||||
])
|
|
||||||
|
|
||||||
debugClaude("memory search response", {
|
|
||||||
success: response?.success,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response?.success && response?.data) {
|
|
||||||
const textareaElement = document.querySelector(
|
|
||||||
'div[contenteditable="true"]',
|
|
||||||
) as HTMLElement
|
|
||||||
|
|
||||||
if (textareaElement) {
|
|
||||||
const memoryText = showMemorySuggestion(
|
|
||||||
"claude",
|
|
||||||
textareaElement,
|
|
||||||
response.data,
|
|
||||||
)
|
|
||||||
debugClaude("memory suggestion rendered", {
|
|
||||||
memoryLength: memoryText.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
|
||||||
response.data,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isAutoSearch) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "found")
|
|
||||||
} else {
|
|
||||||
updateClaudeIconFeedback("Included Memories", iconElement)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Claude input area not found after successful memory fetch",
|
|
||||||
)
|
|
||||||
if (isAutoSearch) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "found")
|
|
||||||
} else {
|
|
||||||
updateClaudeIconFeedback("Memories found", iconElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn("No memories found or API response invalid for Claude")
|
|
||||||
if (isAutoSearch) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "none")
|
|
||||||
} else {
|
|
||||||
updateClaudeIconFeedback("No memories found", iconElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting related memories for Claude:", error)
|
|
||||||
try {
|
|
||||||
const icon = document.querySelector(
|
|
||||||
'[id*="sm-claude-input-bar-element"]',
|
|
||||||
) as HTMLElement
|
|
||||||
if (icon) {
|
|
||||||
if (
|
|
||||||
actionSource === POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED
|
|
||||||
) {
|
|
||||||
setMemoryMarkerStatus(icon, "error")
|
|
||||||
} else {
|
|
||||||
updateClaudeIconFeedback("Error fetching memories", icon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (feedbackError) {
|
|
||||||
console.error("Failed to update Claude error feedback:", feedbackError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClaudeMemoryDialog(): HTMLElement | null {
|
|
||||||
const dialogs = Array.from(
|
|
||||||
document.querySelectorAll<HTMLElement>('[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<HTMLElement>("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<HTMLElement>(
|
|
||||||
"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 = `
|
|
||||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap;">
|
|
||||||
<img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
|
|
||||||
<span style="white-space: nowrap;">Save to supermemory</span>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
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<HTMLElement>("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",
|
|
||||||
})
|
|
||||||
|
|
||||||
debugClaude("memory dialog saved", {
|
|
||||||
success: response.success,
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
|
||||||
resetAfter = 0,
|
|
||||||
) {
|
|
||||||
const memories = iconElement.dataset.memoriesData
|
|
||||||
const fallbackReset =
|
|
||||||
resetAfter || (message === "Included Memories" ? 0 : 2200)
|
|
||||||
|
|
||||||
if (message === "Included Memories" || message === "Memories found") {
|
|
||||||
setMemoryMarkerStatus(iconElement, "found")
|
|
||||||
showMarkerPopover(iconElement, "Included Memories", memories)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.toLowerCase().includes("searching")) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "searching")
|
|
||||||
showMarkerPopover(iconElement, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setMemoryMarkerStatus(
|
|
||||||
iconElement,
|
|
||||||
message.toLowerCase().includes("error") ? "error" : "none",
|
|
||||||
)
|
|
||||||
showMarkerPopover(iconElement, message, undefined, fallbackReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupClaudePromptCapture() {
|
|
||||||
if (document.body.hasAttribute("data-claude-prompt-capture-setup")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
document.body.setAttribute("data-claude-prompt-capture-setup", "true")
|
|
||||||
const captureClaudePromptContent = async (source: string) => {
|
|
||||||
const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false
|
|
||||||
|
|
||||||
if (!autoCapture) {
|
|
||||||
debugClaude("auto prompt capture disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let promptContent = ""
|
|
||||||
|
|
||||||
const contentEditableDiv = document.querySelector(
|
|
||||||
'div[contenteditable="true"]',
|
|
||||||
) as HTMLElement
|
|
||||||
if (contentEditableDiv) {
|
|
||||||
promptContent =
|
|
||||||
contentEditableDiv.textContent || contentEditableDiv.innerText || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!promptContent) {
|
|
||||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement
|
|
||||||
if (textarea) {
|
|
||||||
promptContent = textarea.value || ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (promptContent.trim()) {
|
|
||||||
debugClaude("prompt submitted", {
|
|
||||||
source,
|
|
||||||
promptLength: promptContent.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.CAPTURE_PROMPT,
|
|
||||||
data: {
|
|
||||||
prompt: promptContent,
|
|
||||||
platform: "claude",
|
|
||||||
source: window.location.href,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sending Claude prompt to background:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons = document.querySelectorAll(
|
|
||||||
'[id*="sm-claude-input-bar-element"]',
|
|
||||||
)
|
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
if (iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (contentEditableDiv?.dataset.supermemories) {
|
|
||||||
clearMemorySuggestion("claude", contentEditableDiv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"click",
|
|
||||||
async (event) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
if (target.closest('[data-supermemory-connected-indicator="true"]')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendButton = target.closest("button")
|
|
||||||
|
|
||||||
if (
|
|
||||||
sendButton &&
|
|
||||||
buttonLabel(sendButton as HTMLButtonElement).match(/send|submit/i)
|
|
||||||
) {
|
|
||||||
await captureClaudePromptContent("button click")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"keydown",
|
|
||||||
async (event) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
|
|
||||||
const activeInput =
|
|
||||||
(target.closest('div[contenteditable="true"]') as HTMLElement | null) ||
|
|
||||||
(target.matches("textarea") ? (target as HTMLTextAreaElement) : null)
|
|
||||||
if (acceptMemorySuggestion(event, "claude", activeInput)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(target.matches('div[contenteditable="true"]') ||
|
|
||||||
target.matches(".ProseMirror") ||
|
|
||||||
target.matches("textarea") ||
|
|
||||||
target.closest('div[contenteditable="true"]') ||
|
|
||||||
target.closest(".ProseMirror")) &&
|
|
||||||
event.key === "Enter" &&
|
|
||||||
!event.shiftKey
|
|
||||||
) {
|
|
||||||
await captureClaudePromptContent("Enter key")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupClaudeAutoFetch() {
|
|
||||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
|
||||||
if (!autoSearch) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const textareaElement = document.querySelector(
|
|
||||||
'div[contenteditable="true"]',
|
|
||||||
) as HTMLElement
|
|
||||||
|
|
||||||
if (
|
|
||||||
!textareaElement ||
|
|
||||||
textareaElement.hasAttribute("data-supermemory-auto-fetch")
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
textareaElement.setAttribute("data-supermemory-auto-fetch", "true")
|
|
||||||
|
|
||||||
const handleInput = () => {
|
|
||||||
const content = textareaElement.textContent?.trim() || ""
|
|
||||||
syncAcceptedSupermemoryState(textareaElement)
|
|
||||||
|
|
||||||
if (content.length === 0) {
|
|
||||||
clearMemorySuggestion("claude", textareaElement)
|
|
||||||
document
|
|
||||||
.querySelectorAll('[id*="sm-claude-input-bar-element"]')
|
|
||||||
.forEach((icon) => {
|
|
||||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (claudeDebounceTimeout) {
|
|
||||||
clearTimeout(claudeDebounceTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeDebounceTimeout = setTimeout(async () => {
|
|
||||||
if (hasAcceptedSupermemoryContext(textareaElement)) {
|
|
||||||
clearMemorySuggestion("claude", textareaElement)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length > 2) {
|
|
||||||
await getRelatedMemoriesForClaude(
|
|
||||||
POSTHOG_EVENT_KEY.CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED,
|
|
||||||
)
|
|
||||||
} else if (content.length === 0) {
|
|
||||||
const icons = document.querySelectorAll(
|
|
||||||
'[id*="sm-claude-input-bar-element"]',
|
|
||||||
)
|
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
setMemoryMarkerStatus(iconElement, "neutral")
|
|
||||||
if (iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (textareaElement.dataset.supermemories) {
|
|
||||||
clearMemorySuggestion("claude", textareaElement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
textareaElement.addEventListener("input", handleInput)
|
|
||||||
}
|
|
||||||
|
|
@ -1,664 +0,0 @@
|
||||||
import {
|
|
||||||
DOMAINS,
|
|
||||||
ELEMENT_IDS,
|
|
||||||
MESSAGE_TYPES,
|
|
||||||
POSTHOG_EVENT_KEY,
|
|
||||||
UI_CONFIG,
|
|
||||||
} from "../../utils/constants"
|
|
||||||
import {
|
|
||||||
autoCapturePromptsEnabled,
|
|
||||||
autoSearchEnabled,
|
|
||||||
} from "../../utils/storage"
|
|
||||||
import {
|
|
||||||
createGeminiInputBarElement,
|
|
||||||
DOMUtils,
|
|
||||||
} from "../../utils/ui-components"
|
|
||||||
import {
|
|
||||||
acceptMemorySuggestion,
|
|
||||||
clearMemorySuggestion,
|
|
||||||
hasAcceptedSupermemoryContext,
|
|
||||||
serializeMemoriesForDataset,
|
|
||||||
setMemoryMarkerStatus,
|
|
||||||
showLoadingSuggestion,
|
|
||||||
showMarkerPopover,
|
|
||||||
showMemorySuggestion,
|
|
||||||
syncAcceptedSupermemoryState,
|
|
||||||
} from "./memory-suggestion"
|
|
||||||
|
|
||||||
let geminiDebounceTimeout: NodeJS.Timeout | null = null
|
|
||||||
let geminiRouteObserver: MutationObserver | null = null
|
|
||||||
let geminiUrlCheckInterval: NodeJS.Timeout | null = null
|
|
||||||
let geminiObserverThrottle: NodeJS.Timeout | null = null
|
|
||||||
const GEMINI_DEBUG = false
|
|
||||||
const GEMINI_LOG_PREFIX = "[supermemory:gemini]"
|
|
||||||
|
|
||||||
type GeminiInput = HTMLElement | HTMLTextAreaElement
|
|
||||||
|
|
||||||
export function initializeGemini() {
|
|
||||||
debugGemini("initializeGemini called", {
|
|
||||||
host: window.location.hostname,
|
|
||||||
href: window.location.href,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!DOMUtils.isOnDomain(DOMAINS.GEMINI)) {
|
|
||||||
debugGemini("not on Gemini domain, skipping")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.body.hasAttribute("data-gemini-initialized")) {
|
|
||||||
debugGemini("already initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
addSupermemoryIconToGeminiInput()
|
|
||||||
setupGeminiAutoFetch()
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
setupGeminiPromptCapture()
|
|
||||||
setupGeminiRouteChangeDetection()
|
|
||||||
|
|
||||||
document.body.setAttribute("data-gemini-initialized", "true")
|
|
||||||
debugGemini("initialized listeners")
|
|
||||||
}
|
|
||||||
|
|
||||||
function debugGemini(message: string, data?: unknown) {
|
|
||||||
if (!GEMINI_DEBUG) return
|
|
||||||
|
|
||||||
if (data === undefined) {
|
|
||||||
console.log(GEMINI_LOG_PREFIX, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(GEMINI_LOG_PREFIX, message, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupGeminiRouteChangeDetection() {
|
|
||||||
if (geminiRouteObserver) {
|
|
||||||
geminiRouteObserver.disconnect()
|
|
||||||
}
|
|
||||||
if (geminiUrlCheckInterval) {
|
|
||||||
clearInterval(geminiUrlCheckInterval)
|
|
||||||
}
|
|
||||||
if (geminiObserverThrottle) {
|
|
||||||
clearTimeout(geminiObserverThrottle)
|
|
||||||
geminiObserverThrottle = null
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentUrl = window.location.href
|
|
||||||
|
|
||||||
const recheckGeminiUI = () => {
|
|
||||||
addSupermemoryIconToGeminiInput()
|
|
||||||
setupGeminiAutoFetch()
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkForRouteChange = () => {
|
|
||||||
if (window.location.href !== currentUrl) {
|
|
||||||
currentUrl = window.location.href
|
|
||||||
debugGemini("route changed, rechecking UI", currentUrl)
|
|
||||||
setTimeout(recheckGeminiUI, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
geminiUrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
|
||||||
|
|
||||||
geminiRouteObserver = new MutationObserver((mutations) => {
|
|
||||||
if (geminiObserverThrottle) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldRecheck = mutations.some((mutation) =>
|
|
||||||
Array.from(mutation.addedNodes).some((node) => {
|
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = node as Element
|
|
||||||
return (
|
|
||||||
element.matches?.("rich-textarea, textarea, button") ||
|
|
||||||
element.matches?.('[contenteditable="true"]') ||
|
|
||||||
!!element.querySelector?.(
|
|
||||||
'rich-textarea, textarea, button, [contenteditable="true"]',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (shouldRecheck) {
|
|
||||||
geminiObserverThrottle = setTimeout(() => {
|
|
||||||
geminiObserverThrottle = null
|
|
||||||
debugGemini("DOM changed near Gemini composer, rechecking UI")
|
|
||||||
recheckGeminiUI()
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
geminiRouteObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to set up Gemini route observer:", error)
|
|
||||||
if (geminiUrlCheckInterval) {
|
|
||||||
clearInterval(geminiUrlCheckInterval)
|
|
||||||
}
|
|
||||||
geminiUrlCheckInterval = setInterval(checkForRouteChange, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSupermemoryIconToGeminiInput() {
|
|
||||||
const input = getGeminiPromptInput()
|
|
||||||
if (!input) {
|
|
||||||
debugGemini("prompt input not found", getGeminiDomSnapshot())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const composer = findGeminiComposerRoot(input)
|
|
||||||
if (!composer?.querySelector) {
|
|
||||||
debugGemini("composer root not found", describeElement(input))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingMarkers = Array.from(
|
|
||||||
document.querySelectorAll(
|
|
||||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (existingMarkers.length > 1) {
|
|
||||||
debugGemini("removed duplicate markers", existingMarkers.length)
|
|
||||||
for (const marker of existingMarkers) {
|
|
||||||
marker.remove()
|
|
||||||
}
|
|
||||||
} else if (existingMarkers.length === 1) {
|
|
||||||
debugGemini("marker already exists")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttons = findGeminiComposerButtons(input, composer)
|
|
||||||
debugGemini("candidate Gemini buttons", {
|
|
||||||
input: describeElement(input),
|
|
||||||
composer: describeElement(composer),
|
|
||||||
buttons: buttons.map((button) => ({
|
|
||||||
label: buttonLabel(button),
|
|
||||||
element: describeElement(button),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
const micButton = buttons.find((button) => isGeminiMicButton(button))
|
|
||||||
const sendButton = buttons.find((button) => isGeminiSendButton(button))
|
|
||||||
const anchorButton = micButton || sendButton || buttons[buttons.length - 1]
|
|
||||||
const anchorSlot = findGeminiButtonSlot(anchorButton, composer)
|
|
||||||
const targetContainer =
|
|
||||||
anchorSlot?.parentElement ||
|
|
||||||
(input.closest("rich-textarea") as HTMLElement | null)?.parentElement ||
|
|
||||||
input.parentElement
|
|
||||||
|
|
||||||
if (!targetContainer) {
|
|
||||||
debugGemini("could not find insertion target", {
|
|
||||||
anchor: anchorButton ? describeElement(anchorButton) : null,
|
|
||||||
input: describeElement(input),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const supermemoryIcon = createGeminiInputBarElement(async () => {
|
|
||||||
await getRelatedMemoriesForGemini(
|
|
||||||
POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_SEARCHED,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
supermemoryIcon.id = `${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
|
||||||
|
|
||||||
if (anchorSlot?.parentElement === targetContainer) {
|
|
||||||
targetContainer.insertBefore(supermemoryIcon, anchorSlot)
|
|
||||||
debugGemini("inserted marker before anchor button", {
|
|
||||||
anchorLabel: anchorButton ? buttonLabel(anchorButton) : null,
|
|
||||||
anchorSlot: describeElement(anchorSlot),
|
|
||||||
target: describeElement(targetContainer),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetContainer.appendChild(supermemoryIcon)
|
|
||||||
debugGemini("inserted marker into fallback target", {
|
|
||||||
target: describeElement(targetContainer),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGeminiPromptInput(): GeminiInput | null {
|
|
||||||
return document.querySelector(
|
|
||||||
'rich-textarea .ql-editor[contenteditable="true"], rich-textarea [contenteditable="true"], .ql-editor[contenteditable="true"], div[contenteditable="true"], textarea',
|
|
||||||
) as GeminiInput | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGeminiComposerRoot(input: GeminiInput): HTMLElement {
|
|
||||||
const form = input.closest("form") as HTMLElement | null
|
|
||||||
if (form) return form
|
|
||||||
|
|
||||||
let current: HTMLElement | null = input
|
|
||||||
for (let depth = 0; current && depth < 8; depth += 1) {
|
|
||||||
if (current.querySelectorAll("button").length >= 2) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
current = current.parentElement
|
|
||||||
}
|
|
||||||
|
|
||||||
return input.parentElement || document.body
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGeminiComposerButtons(
|
|
||||||
input: GeminiInput,
|
|
||||||
composer: HTMLElement,
|
|
||||||
): HTMLButtonElement[] {
|
|
||||||
const composerButtons = Array.from(composer.querySelectorAll("button"))
|
|
||||||
if (composerButtons.length > 0) {
|
|
||||||
return composerButtons
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputRect = input.getBoundingClientRect()
|
|
||||||
const allButtons = Array.from(document.querySelectorAll("button"))
|
|
||||||
|
|
||||||
return allButtons.filter((button) => {
|
|
||||||
const rect = button.getBoundingClientRect()
|
|
||||||
const verticallyNear =
|
|
||||||
Math.abs(
|
|
||||||
rect.top + rect.height / 2 - (inputRect.top + inputRect.height / 2),
|
|
||||||
) < 120
|
|
||||||
const horizontallyNear =
|
|
||||||
rect.left > inputRect.left - 80 && rect.left < inputRect.right + 240
|
|
||||||
|
|
||||||
return verticallyNear && horizontallyNear
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function buttonLabel(button: HTMLButtonElement): string {
|
|
||||||
return [
|
|
||||||
button.getAttribute("aria-label"),
|
|
||||||
button.getAttribute("title"),
|
|
||||||
button.getAttribute("data-testid"),
|
|
||||||
button.getAttribute("data-test-id"),
|
|
||||||
button.getAttribute("jsname"),
|
|
||||||
button.textContent,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGeminiMicButton(button: HTMLButtonElement): boolean {
|
|
||||||
return /mic|microphone|voice|dictate|audio/i.test(buttonLabel(button))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGeminiSendButton(button: HTMLButtonElement): boolean {
|
|
||||||
const label = buttonLabel(button)
|
|
||||||
if (/send|submit/i.test(label)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return !!button.querySelector(
|
|
||||||
'mat-icon[fonticon="send"], mat-icon[data-mat-icon-name="send"], [data-icon-name="send"]',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGeminiButtonSlot(
|
|
||||||
button: HTMLButtonElement | undefined,
|
|
||||||
composer: HTMLElement,
|
|
||||||
): HTMLElement | null {
|
|
||||||
if (!button) return null
|
|
||||||
|
|
||||||
let current: HTMLElement | null = button
|
|
||||||
while (current?.parentElement && current.parentElement !== composer) {
|
|
||||||
const parent: HTMLElement = current.parentElement
|
|
||||||
const parentStyle = window.getComputedStyle(parent)
|
|
||||||
const hasSiblingControls = parent.children.length > 1
|
|
||||||
const isRow =
|
|
||||||
parentStyle.display.includes("flex") &&
|
|
||||||
parentStyle.flexDirection !== "column"
|
|
||||||
|
|
||||||
if (hasSiblingControls && isRow) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
current = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
return current || button
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeElement(element: Element | null): string | null {
|
|
||||||
if (!element) return null
|
|
||||||
|
|
||||||
const parts = [element.tagName.toLowerCase()]
|
|
||||||
if (element.id) parts.push(`#${element.id}`)
|
|
||||||
if (element.className && typeof element.className === "string") {
|
|
||||||
parts.push(
|
|
||||||
`.${element.className.trim().split(/\s+/).slice(0, 4).join(".")}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const attr of ["aria-label", "data-testid", "data-test-id", "role"]) {
|
|
||||||
const value = element.getAttribute(attr)
|
|
||||||
if (value) parts.push(`[${attr}="${value}"]`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGeminiDomSnapshot() {
|
|
||||||
return {
|
|
||||||
richTextareas: document.querySelectorAll("rich-textarea").length,
|
|
||||||
qlEditors: document.querySelectorAll(".ql-editor").length,
|
|
||||||
contenteditables: document.querySelectorAll('[contenteditable="true"]')
|
|
||||||
.length,
|
|
||||||
textareas: document.querySelectorAll("textarea").length,
|
|
||||||
buttons: document.querySelectorAll("button").length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInputText(input: GeminiInput | null): string {
|
|
||||||
if (!input) return ""
|
|
||||||
if (input instanceof HTMLTextAreaElement) {
|
|
||||||
return input.value || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return input.innerText || input.textContent || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRelatedMemoriesForGemini(actionSource: string) {
|
|
||||||
try {
|
|
||||||
const isAutoSearch =
|
|
||||||
actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED
|
|
||||||
const input = getGeminiPromptInput()
|
|
||||||
const userQuery = getInputText(input).trim()
|
|
||||||
debugGemini("manual/auto memory search requested", {
|
|
||||||
actionSource,
|
|
||||||
hasInput: !!input,
|
|
||||||
queryLength: userQuery.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!userQuery) {
|
|
||||||
debugGemini("memory search skipped because query is empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconElement = document.querySelector(
|
|
||||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
|
||||||
) as HTMLElement | null
|
|
||||||
|
|
||||||
if (!iconElement) {
|
|
||||||
console.warn("Gemini icon element not found, cannot update feedback")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input && isAutoSearch) {
|
|
||||||
showLoadingSuggestion("gemini", input)
|
|
||||||
}
|
|
||||||
setMemoryMarkerStatus(iconElement, "searching")
|
|
||||||
if (!isAutoSearch) {
|
|
||||||
updateGeminiIconFeedback("Searching memories...", iconElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error("Memory search timeout")),
|
|
||||||
UI_CONFIG.API_REQUEST_TIMEOUT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = (await Promise.race([
|
|
||||||
browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.GET_RELATED_MEMORIES,
|
|
||||||
data: userQuery,
|
|
||||||
actionSource,
|
|
||||||
}),
|
|
||||||
timeoutPromise,
|
|
||||||
])) as { success?: boolean; data?: string }
|
|
||||||
|
|
||||||
debugGemini("memory search response", response)
|
|
||||||
|
|
||||||
if (response?.success && response?.data && input) {
|
|
||||||
const memoryText = showMemorySuggestion("gemini", input, response.data)
|
|
||||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
|
||||||
response.data,
|
|
||||||
)
|
|
||||||
iconElement.dataset.supermemories = memoryText
|
|
||||||
if (isAutoSearch) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "found")
|
|
||||||
} else {
|
|
||||||
updateGeminiIconFeedback("Included Memories", iconElement)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAutoSearch) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "none")
|
|
||||||
} else {
|
|
||||||
updateGeminiIconFeedback("No memories found", iconElement, 1800)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting related memories for Gemini:", error)
|
|
||||||
const iconElement = document.querySelector(
|
|
||||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
|
||||||
) as HTMLElement | null
|
|
||||||
if (iconElement) {
|
|
||||||
if (
|
|
||||||
actionSource === POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED
|
|
||||||
) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "error")
|
|
||||||
} else {
|
|
||||||
updateGeminiIconFeedback("Error fetching memories", iconElement, 1800)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateGeminiIconFeedback(
|
|
||||||
message: string,
|
|
||||||
iconElement: HTMLElement,
|
|
||||||
resetAfter = 0,
|
|
||||||
) {
|
|
||||||
const memories = iconElement.dataset.memoriesData
|
|
||||||
const fallbackReset =
|
|
||||||
resetAfter || (message === "Included Memories" ? 0 : 2200)
|
|
||||||
|
|
||||||
if (message === "Included Memories" || message === "Memories found") {
|
|
||||||
setMemoryMarkerStatus(iconElement, "found")
|
|
||||||
showMarkerPopover(iconElement, "Included Memories", memories)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.toLowerCase().includes("searching")) {
|
|
||||||
setMemoryMarkerStatus(iconElement, "searching")
|
|
||||||
showMarkerPopover(iconElement, message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setMemoryMarkerStatus(
|
|
||||||
iconElement,
|
|
||||||
message.toLowerCase().includes("error") ? "error" : "none",
|
|
||||||
)
|
|
||||||
showMarkerPopover(iconElement, message, undefined, fallbackReset)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupGeminiPromptCapture() {
|
|
||||||
if (document.body.hasAttribute("data-gemini-prompt-capture-setup")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.setAttribute("data-gemini-prompt-capture-setup", "true")
|
|
||||||
|
|
||||||
const captureGeminiPromptContent = async (source: string) => {
|
|
||||||
const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false
|
|
||||||
debugGemini("capture requested", { source, autoCapture })
|
|
||||||
|
|
||||||
if (!autoCapture) {
|
|
||||||
debugGemini("auto prompt capture disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = getGeminiPromptInput()
|
|
||||||
const promptContent = getInputText(input)
|
|
||||||
debugGemini("capture input state", {
|
|
||||||
hasInput: !!input,
|
|
||||||
promptLength: promptContent.length,
|
|
||||||
hasStoredMemories: !!input?.dataset.supermemories,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (promptContent.trim()) {
|
|
||||||
try {
|
|
||||||
const response = await browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.CAPTURE_PROMPT,
|
|
||||||
data: {
|
|
||||||
prompt: promptContent,
|
|
||||||
platform: "gemini",
|
|
||||||
source: window.location.href,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
debugGemini("capture response", response)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sending Gemini prompt to background:", error)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debugGemini("capture skipped because prompt is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons = document.querySelectorAll(
|
|
||||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
|
||||||
)
|
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
iconElement.querySelector("[data-supermemory-status-badge]")?.remove()
|
|
||||||
delete iconElement.dataset.supermemoryStatus
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
if (iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input?.dataset.supermemories) {
|
|
||||||
clearMemorySuggestion("gemini", input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"click",
|
|
||||||
async (event) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
if (target.closest('[data-supermemory-connected-indicator="true"]')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendButton = target.closest("button")
|
|
||||||
if (sendButton && isGeminiSendButton(sendButton as HTMLButtonElement)) {
|
|
||||||
debugGemini("send button click detected", {
|
|
||||||
label: buttonLabel(sendButton as HTMLButtonElement),
|
|
||||||
element: describeElement(sendButton),
|
|
||||||
})
|
|
||||||
await captureGeminiPromptContent("button click")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
|
|
||||||
document.addEventListener(
|
|
||||||
"keydown",
|
|
||||||
async (event) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
|
|
||||||
const activeInput =
|
|
||||||
(target.closest('[contenteditable="true"]') as GeminiInput | null) ||
|
|
||||||
(target.matches("textarea") ? (target as HTMLTextAreaElement) : null)
|
|
||||||
if (acceptMemorySuggestion(event, "gemini", activeInput)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
(target.matches("textarea") ||
|
|
||||||
target.matches('[contenteditable="true"]') ||
|
|
||||||
target.closest('[contenteditable="true"]')) &&
|
|
||||||
event.key === "Enter" &&
|
|
||||||
!event.shiftKey
|
|
||||||
) {
|
|
||||||
debugGemini("Enter submit detected", {
|
|
||||||
target: describeElement(target),
|
|
||||||
})
|
|
||||||
await captureGeminiPromptContent("Enter key")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupGeminiAutoFetch() {
|
|
||||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
|
||||||
debugGemini("setup auto fetch", { autoSearch })
|
|
||||||
if (!autoSearch) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = getGeminiPromptInput()
|
|
||||||
if (!input || input.hasAttribute("data-supermemory-auto-fetch")) {
|
|
||||||
debugGemini("auto fetch skipped", {
|
|
||||||
hasInput: !!input,
|
|
||||||
alreadyAttached: input?.hasAttribute("data-supermemory-auto-fetch"),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input.setAttribute("data-supermemory-auto-fetch", "true")
|
|
||||||
debugGemini("auto fetch attached", describeElement(input))
|
|
||||||
|
|
||||||
const handleInput = () => {
|
|
||||||
const content = getInputText(input).trim()
|
|
||||||
syncAcceptedSupermemoryState(input)
|
|
||||||
|
|
||||||
if (content.length === 0) {
|
|
||||||
clearMemorySuggestion("gemini", input)
|
|
||||||
document
|
|
||||||
.querySelectorAll(`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`)
|
|
||||||
.forEach((icon) => {
|
|
||||||
setMemoryMarkerStatus(icon as HTMLElement, "neutral")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geminiDebounceTimeout) {
|
|
||||||
clearTimeout(geminiDebounceTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
geminiDebounceTimeout = setTimeout(async () => {
|
|
||||||
if (hasAcceptedSupermemoryContext(input)) {
|
|
||||||
clearMemorySuggestion("gemini", input)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length > 2) {
|
|
||||||
await getRelatedMemoriesForGemini(
|
|
||||||
POSTHOG_EVENT_KEY.GEMINI_CHAT_MEMORIES_AUTO_SEARCHED,
|
|
||||||
)
|
|
||||||
} else if (content.length === 0) {
|
|
||||||
const icons = document.querySelectorAll(
|
|
||||||
`[id*="${ELEMENT_IDS.GEMINI_INPUT_BAR_ELEMENT}"]`,
|
|
||||||
)
|
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
iconElement.querySelector("[data-supermemory-status-badge]")?.remove()
|
|
||||||
delete iconElement.dataset.supermemoryStatus
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
if (iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (input.dataset.supermemories) {
|
|
||||||
clearMemorySuggestion("gemini", input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener("input", handleInput)
|
|
||||||
}
|
|
||||||
|
|
@ -1,445 +0,0 @@
|
||||||
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<HTMLElement>(
|
|
||||||
"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<HTMLElement>(
|
|
||||||
"button, a, [role='button'], [role='tab']",
|
|
||||||
) || matchingElement
|
|
||||||
|
|
||||||
clickableElement.click()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGrokSettingsDialog() {
|
|
||||||
return Array.from(
|
|
||||||
document.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>("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<HTMLElement>(
|
|
||||||
"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 = `
|
|
||||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap;">
|
|
||||||
<img src="${iconUrl}" alt="supermemory" style="width: 16px; height: 16px; flex-shrink: 0; border-radius: 2px;" />
|
|
||||||
<span style="white-space: nowrap;">Save to supermemory</span>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
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<HTMLButtonElement>("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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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 { initializeGemini } from "./gemini"
|
|
||||||
import {
|
|
||||||
saveMemory,
|
|
||||||
setupGlobalKeyboardShortcut,
|
|
||||||
setupStorageListener,
|
|
||||||
} from "./shared"
|
|
||||||
import { initializeT3 } from "./t3"
|
|
||||||
import {
|
|
||||||
handleTwitterNavigation,
|
|
||||||
initializeTwitter,
|
|
||||||
openImportModal,
|
|
||||||
updateTwitterImportUI,
|
|
||||||
} from "./twitter"
|
|
||||||
|
|
||||||
export default defineContentScript({
|
|
||||||
matches: ["<all_urls>"],
|
|
||||||
main() {
|
|
||||||
// Setup global event listeners
|
|
||||||
browser.runtime.onMessage.addListener((message) => {
|
|
||||||
if (message.action === MESSAGE_TYPES.SHOW_TOAST) {
|
|
||||||
DOMUtils.showToast(message.state)
|
|
||||||
} else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) {
|
|
||||||
return saveMemory(message.actionSource || "content_script")
|
|
||||||
} else if (message.action === MESSAGE_TYPES.TWITTER_IMPORT_OPEN_MODAL) {
|
|
||||||
return openImportModal()
|
|
||||||
} else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) {
|
|
||||||
updateTwitterImportUI(message)
|
|
||||||
} else if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
|
||||||
updateTwitterImportUI(message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Setup global keyboard shortcuts
|
|
||||||
setupGlobalKeyboardShortcut()
|
|
||||||
|
|
||||||
// Setup storage listener
|
|
||||||
setupStorageListener()
|
|
||||||
|
|
||||||
// Observer for dynamic content changes
|
|
||||||
const observeForDynamicChanges = () => {
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
if (DOMUtils.isOnDomain(DOMAINS.CHATGPT)) {
|
|
||||||
initializeChatGPT()
|
|
||||||
}
|
|
||||||
if (DOMUtils.isOnDomain(DOMAINS.CLAUDE)) {
|
|
||||||
initializeClaude()
|
|
||||||
}
|
|
||||||
if (DOMUtils.isOnDomain(DOMAINS.GROK)) {
|
|
||||||
initializeGrok()
|
|
||||||
}
|
|
||||||
if (DOMUtils.isOnDomain(DOMAINS.GEMINI)) {
|
|
||||||
initializeGemini()
|
|
||||||
}
|
|
||||||
if (DOMUtils.isOnDomain(DOMAINS.T3)) {
|
|
||||||
initializeT3()
|
|
||||||
}
|
|
||||||
if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
|
||||||
handleTwitterNavigation()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize platform-specific functionality
|
|
||||||
initializeChatGPT()
|
|
||||||
initializeClaude()
|
|
||||||
initializeGrok()
|
|
||||||
initializeGemini()
|
|
||||||
initializeT3()
|
|
||||||
initializeTwitter()
|
|
||||||
|
|
||||||
// Start observing for dynamic changes
|
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener("DOMContentLoaded", observeForDynamicChanges)
|
|
||||||
} else {
|
|
||||||
observeForDynamicChanges()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,446 +0,0 @@
|
||||||
type SuggestionInput = HTMLElement | HTMLTextAreaElement
|
|
||||||
|
|
||||||
const SUGGESTION_ATTR = "data-supermemory-memory-suggestion"
|
|
||||||
const SUPERMEMORY_PREFIX = "Supermemories of user (only for the reference):"
|
|
||||||
const SUPERMEMORY_BLUE = "#1A88FF"
|
|
||||||
|
|
||||||
export function buildSupermemoryText(memories: unknown): string {
|
|
||||||
const memoryText = Array.isArray(memories)
|
|
||||||
? memories.join("").trim()
|
|
||||||
: String(memories || "").trim()
|
|
||||||
|
|
||||||
return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMemoryList(memories: unknown): string[] {
|
|
||||||
const list = Array.isArray(memories)
|
|
||||||
? memories
|
|
||||||
: memories == null
|
|
||||||
? []
|
|
||||||
: [memories]
|
|
||||||
return list
|
|
||||||
.map((memory) => (typeof memory === "string" ? memory : String(memory)))
|
|
||||||
.map((memory) => memory.trim())
|
|
||||||
.filter((memory) => memory.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeMemoriesForDataset(memories: unknown): string {
|
|
||||||
const list = normalizeMemoryList(memories)
|
|
||||||
return list.length > 0 ? JSON.stringify(list) : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMemoriesFromDataset(
|
|
||||||
raw: string | null | undefined,
|
|
||||||
): string[] {
|
|
||||||
if (!raw) return []
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (Array.isArray(parsed)) return normalizeMemoryList(parsed)
|
|
||||||
} catch {
|
|
||||||
// Not JSON — fall through to the legacy delimiter split.
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
.split(/[,\n]/)
|
|
||||||
.map((memory) => memory.trim())
|
|
||||||
.filter((memory) => memory.length > 0 && memory !== ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renumberIncludedMemories(memories: string[]): string[] {
|
|
||||||
return memories.map((memory, index) => {
|
|
||||||
const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "")
|
|
||||||
return `${index + 1}. ${text} \n`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showMemorySuggestion(
|
|
||||||
platform: string,
|
|
||||||
input: SuggestionInput,
|
|
||||||
memories: unknown,
|
|
||||||
): string {
|
|
||||||
const suggestionText = buildSupermemoryText(memories)
|
|
||||||
input.dataset.supermemories = suggestionText
|
|
||||||
delete input.dataset.supermemoriesInjected
|
|
||||||
|
|
||||||
removeMemorySuggestion(platform)
|
|
||||||
|
|
||||||
const anchor = getSuggestionAnchor(input)
|
|
||||||
if (!anchor) return suggestionText
|
|
||||||
|
|
||||||
const previousPosition = window.getComputedStyle(anchor).position
|
|
||||||
if (previousPosition === "static") {
|
|
||||||
anchor.dataset.supermemoryPreviousPosition = "static"
|
|
||||||
anchor.style.position = "relative"
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestion = createSuggestionContainer(platform, input, anchor)
|
|
||||||
suggestion.dataset.supermemorySuggestionState = "ready"
|
|
||||||
suggestion.style.gap = "8px"
|
|
||||||
suggestion.style.alignItems = "center"
|
|
||||||
|
|
||||||
const text = document.createElement("span")
|
|
||||||
text.style.cssText = `
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
`
|
|
||||||
text.textContent = suggestionText.trim()
|
|
||||||
|
|
||||||
const tabKey = document.createElement("span")
|
|
||||||
tabKey.style.cssText = `
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: ${SUPERMEMORY_BLUE};
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.16) inset, 0 6px 18px rgba(26, 136, 255, 0.24);
|
|
||||||
flex-shrink: 0;
|
|
||||||
`
|
|
||||||
tabKey.textContent = "Tab"
|
|
||||||
|
|
||||||
suggestion.appendChild(text)
|
|
||||||
suggestion.appendChild(tabKey)
|
|
||||||
anchor.appendChild(suggestion)
|
|
||||||
|
|
||||||
return suggestionText
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showLoadingSuggestion(
|
|
||||||
platform: string,
|
|
||||||
input: SuggestionInput,
|
|
||||||
) {
|
|
||||||
removeMemorySuggestion(platform)
|
|
||||||
|
|
||||||
const anchor = getSuggestionAnchor(input)
|
|
||||||
if (!anchor) return
|
|
||||||
|
|
||||||
const previousPosition = window.getComputedStyle(anchor).position
|
|
||||||
if (previousPosition === "static") {
|
|
||||||
anchor.dataset.supermemoryPreviousPosition = "static"
|
|
||||||
anchor.style.position = "relative"
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureSuggestionAnimationStyle()
|
|
||||||
|
|
||||||
const suggestion = createSuggestionContainer(platform, input, anchor)
|
|
||||||
suggestion.dataset.supermemorySuggestionState = "loading"
|
|
||||||
suggestion.style.gap = "4px"
|
|
||||||
suggestion.setAttribute("aria-label", "supermemory searching memories")
|
|
||||||
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
const dot = document.createElement("span")
|
|
||||||
dot.style.cssText = `
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: ${SUPERMEMORY_BLUE};
|
|
||||||
animation: supermemorySuggestionDot 1s ease-in-out infinite;
|
|
||||||
animation-delay: ${index * 0.14}s;
|
|
||||||
`
|
|
||||||
suggestion.appendChild(dot)
|
|
||||||
}
|
|
||||||
|
|
||||||
anchor.appendChild(suggestion)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSuggestionContainer(
|
|
||||||
platform: string,
|
|
||||||
input: SuggestionInput,
|
|
||||||
anchor: HTMLElement,
|
|
||||||
): HTMLDivElement {
|
|
||||||
const suggestion = document.createElement("div")
|
|
||||||
suggestion.setAttribute(SUGGESTION_ATTR, platform)
|
|
||||||
const position = getCaretPosition(input, anchor)
|
|
||||||
const verticalOffset = platform === "gemini" ? -10 : 0
|
|
||||||
suggestion.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
left: ${position.left + 6}px;
|
|
||||||
top: ${position.top + verticalOffset}px;
|
|
||||||
max-width: min(540px, calc(100% - ${position.left + 220}px));
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 22px;
|
|
||||||
color: rgba(255, 255, 255, 0.34);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.35;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2147483646;
|
|
||||||
`
|
|
||||||
return suggestion
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeMemorySuggestion(platform: string) {
|
|
||||||
const elements = document.querySelectorAll(
|
|
||||||
`[${SUGGESTION_ATTR}="${platform}"]`,
|
|
||||||
)
|
|
||||||
for (const element of elements) {
|
|
||||||
element.remove()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function acceptMemorySuggestion(
|
|
||||||
event: KeyboardEvent,
|
|
||||||
platform: string,
|
|
||||||
input: SuggestionInput | null,
|
|
||||||
): boolean {
|
|
||||||
if (event.key !== "Tab" || !input?.dataset.supermemories) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
const text = input.dataset.supermemories
|
|
||||||
appendTextToInput(input, text)
|
|
||||||
delete input.dataset.supermemories
|
|
||||||
input.dataset.supermemoriesInjected = "true"
|
|
||||||
removeMemorySuggestion(platform)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasAcceptedSupermemoryContext(
|
|
||||||
input: SuggestionInput | null,
|
|
||||||
): boolean {
|
|
||||||
if (!input) return false
|
|
||||||
const text =
|
|
||||||
input instanceof HTMLTextAreaElement
|
|
||||||
? input.value
|
|
||||||
: input.innerText || input.textContent || ""
|
|
||||||
|
|
||||||
return text.includes(SUPERMEMORY_PREFIX)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncAcceptedSupermemoryState(input: SuggestionInput | null) {
|
|
||||||
if (!input?.dataset.supermemoriesInjected) return
|
|
||||||
|
|
||||||
if (!hasAcceptedSupermemoryContext(input)) {
|
|
||||||
delete input.dataset.supermemoriesInjected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearMemorySuggestion(
|
|
||||||
platform: string,
|
|
||||||
input: SuggestionInput | null,
|
|
||||||
) {
|
|
||||||
removeMemorySuggestion(platform)
|
|
||||||
if (input?.dataset.supermemories) {
|
|
||||||
delete input.dataset.supermemories
|
|
||||||
}
|
|
||||||
if (input?.dataset.supermemoriesInjected) {
|
|
||||||
delete input.dataset.supermemoriesInjected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setMemoryMarkerStatus(
|
|
||||||
iconElement: HTMLElement | null,
|
|
||||||
status: "neutral" | "searching" | "found" | "none" | "error",
|
|
||||||
) {
|
|
||||||
if (!iconElement) return
|
|
||||||
|
|
||||||
iconElement.querySelector("[data-supermemory-status-badge]")?.remove()
|
|
||||||
|
|
||||||
if (status === "neutral" || status === "none") {
|
|
||||||
delete iconElement.dataset.supermemoryStatus
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
iconElement.dataset.supermemoryStatus = status
|
|
||||||
const badge = document.createElement("span")
|
|
||||||
badge.dataset.supermemoryStatusBadge = "true"
|
|
||||||
badge.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
right: 3px;
|
|
||||||
width: ${status === "searching" ? "7px" : "8px"};
|
|
||||||
height: ${status === "searching" ? "7px" : "8px"};
|
|
||||||
border-radius: 999px;
|
|
||||||
background: ${status === "found" ? "#36F3D7" : status === "searching" ? SUPERMEMORY_BLUE : status === "error" ? "#EF4444" : "rgba(255, 255, 255, 0.55)"};
|
|
||||||
border: 1px solid rgba(5, 7, 10, 0.9);
|
|
||||||
box-shadow: ${status === "found" ? "0 0 0 2px rgba(54, 243, 215, 0.18)" : "none"};
|
|
||||||
pointer-events: none;
|
|
||||||
`
|
|
||||||
iconElement.appendChild(badge)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showMarkerPopover(
|
|
||||||
iconElement: HTMLElement,
|
|
||||||
message: string,
|
|
||||||
memories?: string,
|
|
||||||
resetAfter = 0,
|
|
||||||
) {
|
|
||||||
iconElement.querySelector("[data-supermemory-marker-popover]")?.remove()
|
|
||||||
ensureSuggestionAnimationStyle()
|
|
||||||
|
|
||||||
const popover = document.createElement("div")
|
|
||||||
popover.dataset.supermemoryMarkerPopover = "true"
|
|
||||||
popover.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
bottom: calc(100% + 10px);
|
|
||||||
min-width: 168px;
|
|
||||||
max-width: 280px;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(10, 14, 20, 0.96);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
color: #FAFAFA;
|
|
||||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.32);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.35;
|
|
||||||
text-align: left;
|
|
||||||
z-index: 2147483647;
|
|
||||||
pointer-events: auto;
|
|
||||||
`
|
|
||||||
|
|
||||||
const title = document.createElement("div")
|
|
||||||
title.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: ${memories ? "8px" : "0"};
|
|
||||||
`
|
|
||||||
|
|
||||||
if (message.toLowerCase().includes("searching")) {
|
|
||||||
const dots = document.createElement("span")
|
|
||||||
dots.style.cssText = "display: inline-flex; gap: 3px; align-items: center;"
|
|
||||||
for (let index = 0; index < 3; index += 1) {
|
|
||||||
const dot = document.createElement("span")
|
|
||||||
dot.style.cssText = `
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: ${SUPERMEMORY_BLUE};
|
|
||||||
animation: supermemorySuggestionDot 1s ease-in-out infinite;
|
|
||||||
animation-delay: ${index * 0.14}s;
|
|
||||||
`
|
|
||||||
dots.appendChild(dot)
|
|
||||||
}
|
|
||||||
title.appendChild(dots)
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleText = document.createElement("span")
|
|
||||||
titleText.textContent =
|
|
||||||
message === "Included Memories" ? "Included memories" : message
|
|
||||||
title.appendChild(titleText)
|
|
||||||
popover.appendChild(title)
|
|
||||||
|
|
||||||
if (memories) {
|
|
||||||
const list = document.createElement("div")
|
|
||||||
list.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
max-height: 160px;
|
|
||||||
overflow-y: auto;
|
|
||||||
color: rgba(255, 255, 255, 0.76);
|
|
||||||
`
|
|
||||||
|
|
||||||
parseMemoriesFromDataset(memories)
|
|
||||||
.slice(0, 5)
|
|
||||||
.forEach((memory) => {
|
|
||||||
const item = document.createElement("div")
|
|
||||||
item.textContent = memory
|
|
||||||
list.appendChild(item)
|
|
||||||
})
|
|
||||||
|
|
||||||
popover.appendChild(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
iconElement.appendChild(popover)
|
|
||||||
|
|
||||||
if (resetAfter > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
popover.remove()
|
|
||||||
}, resetAfter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureSuggestionAnimationStyle() {
|
|
||||||
if (document.getElementById("supermemory-suggestion-animation-style")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const style = document.createElement("style")
|
|
||||||
style.id = "supermemory-suggestion-animation-style"
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes supermemorySuggestionDot {
|
|
||||||
0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
|
|
||||||
40% { opacity: 1; transform: translateY(-1px); }
|
|
||||||
}
|
|
||||||
`
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSuggestionAnchor(input: SuggestionInput): HTMLElement | null {
|
|
||||||
return (
|
|
||||||
(input.closest("form") as HTMLElement | null) ||
|
|
||||||
(input.closest('[role="textbox"]') as HTMLElement | null)?.parentElement ||
|
|
||||||
input.parentElement
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCaretPosition(input: SuggestionInput, anchor: HTMLElement) {
|
|
||||||
const anchorRect = anchor.getBoundingClientRect()
|
|
||||||
|
|
||||||
if (!(input instanceof HTMLTextAreaElement)) {
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (selection?.rangeCount) {
|
|
||||||
const range = selection.getRangeAt(0).cloneRange()
|
|
||||||
if (input.contains(range.startContainer)) {
|
|
||||||
range.collapse(true)
|
|
||||||
let rect = range.getBoundingClientRect()
|
|
||||||
if (rect.width === 0 && rect.height === 0) {
|
|
||||||
const marker = document.createElement("span")
|
|
||||||
marker.textContent = "\u200b"
|
|
||||||
range.insertNode(marker)
|
|
||||||
rect = marker.getBoundingClientRect()
|
|
||||||
marker.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rect.width || rect.height) {
|
|
||||||
return {
|
|
||||||
left: Math.max(18, rect.right - anchorRect.left + 4),
|
|
||||||
top: Math.max(10, rect.top - anchorRect.top),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputRect = input.getBoundingClientRect()
|
|
||||||
return {
|
|
||||||
left: Math.max(18, inputRect.left - anchorRect.left + 18),
|
|
||||||
top: Math.max(10, inputRect.top - anchorRect.top + 8),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendTextToInput(input: SuggestionInput, text: string) {
|
|
||||||
if (input instanceof HTMLTextAreaElement) {
|
|
||||||
input.value = `${input.value}${text}`
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
input.focus()
|
|
||||||
const selection = window.getSelection()
|
|
||||||
const range = document.createRange()
|
|
||||||
range.selectNodeContents(input)
|
|
||||||
range.collapse(false)
|
|
||||||
range.insertNode(document.createTextNode(text))
|
|
||||||
range.collapse(false)
|
|
||||||
selection?.removeAllRanges()
|
|
||||||
selection?.addRange(range)
|
|
||||||
input.dispatchEvent(
|
|
||||||
new InputEvent("input", { bubbles: true, inputType: "insertText" }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import { MESSAGE_TYPES } from "../../utils/constants"
|
|
||||||
import { bearerToken, userData } from "../../utils/storage"
|
|
||||||
import type { APIResponse } from "../../utils/types"
|
|
||||||
import { DOMUtils } from "../../utils/ui-components"
|
|
||||||
import { default as TurndownService } from "turndown"
|
|
||||||
|
|
||||||
export async function saveMemory(
|
|
||||||
actionSource = "content_script",
|
|
||||||
): Promise<APIResponse> {
|
|
||||||
try {
|
|
||||||
DOMUtils.showToast("loading")
|
|
||||||
|
|
||||||
const highlightedText = window.getSelection()?.toString() || ""
|
|
||||||
const url = window.location.href
|
|
||||||
|
|
||||||
const ogImage =
|
|
||||||
document
|
|
||||||
.querySelector('meta[property="og:image"]')
|
|
||||||
?.getAttribute("content") ||
|
|
||||||
document
|
|
||||||
.querySelector('meta[name="og:image"]')
|
|
||||||
?.getAttribute("content") ||
|
|
||||||
undefined
|
|
||||||
|
|
||||||
const title =
|
|
||||||
document
|
|
||||||
.querySelector('meta[property="og:title"]')
|
|
||||||
?.getAttribute("content") ||
|
|
||||||
document
|
|
||||||
.querySelector('meta[name="og:title"]')
|
|
||||||
?.getAttribute("content") ||
|
|
||||||
document.title ||
|
|
||||||
undefined
|
|
||||||
|
|
||||||
const data: {
|
|
||||||
html?: string
|
|
||||||
markdown?: string
|
|
||||||
highlightedText?: string
|
|
||||||
url: string
|
|
||||||
ogImage?: string
|
|
||||||
title?: string
|
|
||||||
} = {
|
|
||||||
url,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ogImage) {
|
|
||||||
data.ogImage = ogImage
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
data.title = title
|
|
||||||
}
|
|
||||||
|
|
||||||
if (highlightedText) {
|
|
||||||
data.highlightedText = highlightedText
|
|
||||||
} else {
|
|
||||||
const bodyClone = document.body.cloneNode(true) as HTMLElement
|
|
||||||
const scripts = bodyClone.querySelectorAll("script")
|
|
||||||
for (const script of scripts) {
|
|
||||||
script.remove()
|
|
||||||
}
|
|
||||||
const html = bodyClone.innerHTML
|
|
||||||
|
|
||||||
// Convert HTML to markdown
|
|
||||||
const turndownService = new TurndownService()
|
|
||||||
const markdown = turndownService.turndown(html)
|
|
||||||
data.markdown = markdown
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = (await browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.SAVE_MEMORY,
|
|
||||||
data,
|
|
||||||
actionSource,
|
|
||||||
})) as APIResponse
|
|
||||||
|
|
||||||
if (response?.success) {
|
|
||||||
DOMUtils.showToast("success")
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
DOMUtils.showToast("error")
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: response?.error || "Failed to save memory",
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving memory:", error)
|
|
||||||
DOMUtils.showToast("error")
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupGlobalKeyboardShortcut() {
|
|
||||||
document.addEventListener("keydown", async (event) => {
|
|
||||||
if (
|
|
||||||
(event.ctrlKey || event.metaKey) &&
|
|
||||||
event.shiftKey &&
|
|
||||||
event.key === "m"
|
|
||||||
) {
|
|
||||||
event.preventDefault()
|
|
||||||
await saveMemory("keyboard_shortcut")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupStorageListener() {
|
|
||||||
window.addEventListener("message", async (event) => {
|
|
||||||
if (event.source !== window) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const token = event.data.token
|
|
||||||
const user = event.data.userData
|
|
||||||
if (token && user) {
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
window.location.hostname === "localhost" ||
|
|
||||||
window.location.hostname === "supermemory.ai" ||
|
|
||||||
window.location.hostname === "app.supermemory.ai"
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
bearerToken.setValue(token),
|
|
||||||
userData.setValue(user),
|
|
||||||
])
|
|
||||||
} catch {
|
|
||||||
// Do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,744 +0,0 @@
|
||||||
import {
|
|
||||||
DOMAINS,
|
|
||||||
ELEMENT_IDS,
|
|
||||||
MESSAGE_TYPES,
|
|
||||||
POSTHOG_EVENT_KEY,
|
|
||||||
UI_CONFIG,
|
|
||||||
} from "../../utils/constants"
|
|
||||||
import {
|
|
||||||
autoSearchEnabled,
|
|
||||||
autoCapturePromptsEnabled,
|
|
||||||
} from "../../utils/storage"
|
|
||||||
import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components"
|
|
||||||
import {
|
|
||||||
buildSupermemoryText,
|
|
||||||
parseMemoriesFromDataset,
|
|
||||||
renumberIncludedMemories,
|
|
||||||
serializeMemoriesForDataset,
|
|
||||||
} from "./memory-suggestion"
|
|
||||||
|
|
||||||
let t3DebounceTimeout: NodeJS.Timeout | null = null
|
|
||||||
let t3RouteObserver: MutationObserver | null = null
|
|
||||||
let t3UrlCheckInterval: NodeJS.Timeout | null = null
|
|
||||||
let t3ObserverThrottle: NodeJS.Timeout | null = null
|
|
||||||
let t3IncludedPopup: {
|
|
||||||
el: HTMLElement
|
|
||||||
onClick: (event: MouseEvent) => void
|
|
||||||
timer: ReturnType<typeof setTimeout>
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
function disposeT3IncludedPopup() {
|
|
||||||
if (!t3IncludedPopup) return
|
|
||||||
document.removeEventListener("click", t3IncludedPopup.onClick)
|
|
||||||
clearTimeout(t3IncludedPopup.timer)
|
|
||||||
t3IncludedPopup.el.remove()
|
|
||||||
t3IncludedPopup = null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initializeT3() {
|
|
||||||
if (!DOMUtils.isOnDomain(DOMAINS.T3)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.body.hasAttribute("data-t3-initialized")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
addSupermemoryIconToT3Input()
|
|
||||||
setupT3AutoFetch()
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
setupT3PromptCapture()
|
|
||||||
|
|
||||||
setupT3RouteChangeDetection()
|
|
||||||
|
|
||||||
document.body.setAttribute("data-t3-initialized", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupT3RouteChangeDetection() {
|
|
||||||
if (t3RouteObserver) {
|
|
||||||
t3RouteObserver.disconnect()
|
|
||||||
}
|
|
||||||
if (t3UrlCheckInterval) {
|
|
||||||
clearInterval(t3UrlCheckInterval)
|
|
||||||
}
|
|
||||||
if (t3ObserverThrottle) {
|
|
||||||
clearTimeout(t3ObserverThrottle)
|
|
||||||
t3ObserverThrottle = null
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentUrl = window.location.href
|
|
||||||
|
|
||||||
const checkForRouteChange = () => {
|
|
||||||
if (window.location.href !== currentUrl) {
|
|
||||||
disposeT3IncludedPopup()
|
|
||||||
currentUrl = window.location.href
|
|
||||||
setTimeout(() => {
|
|
||||||
addSupermemoryIconToT3Input()
|
|
||||||
setupT3AutoFetch()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t3UrlCheckInterval = setInterval(checkForRouteChange, 2000)
|
|
||||||
|
|
||||||
t3RouteObserver = new MutationObserver((mutations) => {
|
|
||||||
if (t3ObserverThrottle) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldRecheck = false
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
|
|
||||||
mutation.addedNodes.forEach((node) => {
|
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const element = node as Element
|
|
||||||
if (
|
|
||||||
element.querySelector?.("textarea") ||
|
|
||||||
element.querySelector?.('div[contenteditable="true"]') ||
|
|
||||||
element.matches?.("textarea") ||
|
|
||||||
element.matches?.('div[contenteditable="true"]')
|
|
||||||
) {
|
|
||||||
shouldRecheck = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (shouldRecheck) {
|
|
||||||
t3ObserverThrottle = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
t3ObserverThrottle = null
|
|
||||||
addSupermemoryIconToT3Input()
|
|
||||||
setupT3AutoFetch()
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in T3 observer callback:", error)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
t3RouteObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to set up T3 route observer:", error)
|
|
||||||
if (t3UrlCheckInterval) {
|
|
||||||
clearInterval(t3UrlCheckInterval)
|
|
||||||
}
|
|
||||||
t3UrlCheckInterval = setInterval(checkForRouteChange, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSupermemoryIconToT3Input() {
|
|
||||||
const targetContainers = document.querySelectorAll(
|
|
||||||
".flex.min-w-0.items-center.gap-2",
|
|
||||||
)
|
|
||||||
|
|
||||||
const container = targetContainers[0]
|
|
||||||
if (!container) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (container.hasAttribute("data-supermemory-icon-added")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIcon = container.querySelector(
|
|
||||||
`#${ELEMENT_IDS.T3_INPUT_BAR_ELEMENT}`,
|
|
||||||
)
|
|
||||||
if (existingIcon) {
|
|
||||||
container.setAttribute("data-supermemory-icon-added", "true")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const supermemoryIcon = createT3InputBarElement(async () => {
|
|
||||||
await getRelatedMemoriesForT3(POSTHOG_EVENT_KEY.T3_CHAT_MEMORIES_SEARCHED)
|
|
||||||
})
|
|
||||||
|
|
||||||
supermemoryIcon.id = `${ELEMENT_IDS.T3_INPUT_BAR_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`
|
|
||||||
|
|
||||||
container.setAttribute("data-supermemory-icon-added", "true")
|
|
||||||
|
|
||||||
container.insertBefore(supermemoryIcon, container.firstChild)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRelatedMemoriesForT3(actionSource: string) {
|
|
||||||
try {
|
|
||||||
let userQuery = ""
|
|
||||||
|
|
||||||
const supermemoryContainer = document.querySelector(
|
|
||||||
'[data-supermemory-icon-added="true"]',
|
|
||||||
)
|
|
||||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
|
||||||
const textareaElement =
|
|
||||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
|
||||||
"textarea",
|
|
||||||
)
|
|
||||||
userQuery = textareaElement?.value || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userQuery.trim()) {
|
|
||||||
const textareaElement = document.querySelector(
|
|
||||||
'div[contenteditable="true"]',
|
|
||||||
) as HTMLElement
|
|
||||||
userQuery =
|
|
||||||
textareaElement?.innerText || textareaElement?.textContent || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userQuery.trim()) {
|
|
||||||
const textareas = document.querySelectorAll("textarea")
|
|
||||||
for (const textarea of textareas) {
|
|
||||||
const text = (textarea as HTMLTextAreaElement).value
|
|
||||||
if (text?.trim()) {
|
|
||||||
userQuery = text.trim()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userQuery.trim()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = document.querySelector('[id*="sm-t3-input-bar-element"]')
|
|
||||||
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
|
|
||||||
if (!iconElement) {
|
|
||||||
console.warn("T3 icon element not found, cannot update feedback")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateT3IconFeedback("Searching memories...", iconElement)
|
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(
|
|
||||||
() => reject(new Error("Memory search timeout")),
|
|
||||||
UI_CONFIG.API_REQUEST_TIMEOUT,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = await Promise.race([
|
|
||||||
browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.GET_RELATED_MEMORIES,
|
|
||||||
data: userQuery,
|
|
||||||
actionSource: actionSource,
|
|
||||||
}),
|
|
||||||
timeoutPromise,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (response?.success && response?.data) {
|
|
||||||
let textareaElement = null
|
|
||||||
const supermemoryContainer = document.querySelector(
|
|
||||||
'[data-supermemory-icon-added="true"]',
|
|
||||||
)
|
|
||||||
if (supermemoryContainer?.parentElement?.previousElementSibling) {
|
|
||||||
textareaElement =
|
|
||||||
supermemoryContainer.parentElement.previousElementSibling.querySelector(
|
|
||||||
"textarea",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!textareaElement) {
|
|
||||||
textareaElement = document.querySelector(
|
|
||||||
'div[contenteditable="true"]',
|
|
||||||
) as HTMLElement
|
|
||||||
}
|
|
||||||
|
|
||||||
if (textareaElement) {
|
|
||||||
textareaElement.dataset.supermemories = buildSupermemoryText(
|
|
||||||
response.data,
|
|
||||||
)
|
|
||||||
|
|
||||||
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
|
|
||||||
response.data,
|
|
||||||
)
|
|
||||||
|
|
||||||
updateT3IconFeedback("Included Memories", iconElement)
|
|
||||||
} else {
|
|
||||||
console.warn("T3 input area not found after successful memory fetch")
|
|
||||||
updateT3IconFeedback("Memories found", iconElement)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn("No memories found or API response invalid for T3")
|
|
||||||
updateT3IconFeedback("No memories found", iconElement)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error getting related memories for T3:", error)
|
|
||||||
try {
|
|
||||||
const icon = document.querySelector(
|
|
||||||
'[id*="sm-t3-input-bar-element"]',
|
|
||||||
) as HTMLElement
|
|
||||||
if (icon) {
|
|
||||||
updateT3IconFeedback("Error fetching memories", icon)
|
|
||||||
}
|
|
||||||
} catch (feedbackError) {
|
|
||||||
console.error("Failed to update T3 error feedback:", feedbackError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateT3IconFeedback(
|
|
||||||
message: string,
|
|
||||||
iconElement: HTMLElement,
|
|
||||||
resetAfter = 0,
|
|
||||||
) {
|
|
||||||
if (!iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.dataset.originalHtml = iconElement.innerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
disposeT3IncludedPopup()
|
|
||||||
|
|
||||||
const feedbackDiv = document.createElement("div")
|
|
||||||
feedbackDiv.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: #513EA9;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: white;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: ${message === "Included Memories" ? "pointer" : "default"};
|
|
||||||
position: relative;
|
|
||||||
`
|
|
||||||
|
|
||||||
feedbackDiv.innerHTML = `
|
|
||||||
<span>✓</span>
|
|
||||||
<span>${message}</span>
|
|
||||||
`
|
|
||||||
|
|
||||||
if (message === "Included Memories" && iconElement.dataset.memoriesData) {
|
|
||||||
const popup = document.createElement("div")
|
|
||||||
popup.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
bottom: 80px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: white;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
|
||||||
max-width: 500px;
|
|
||||||
max-height: 400px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 999999;
|
|
||||||
display: none;
|
|
||||||
border: 1px solid #333;
|
|
||||||
`
|
|
||||||
|
|
||||||
const header = document.createElement("div")
|
|
||||||
header.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
opacity: 0.8;
|
|
||||||
`
|
|
||||||
header.innerHTML = `
|
|
||||||
<span style="font-weight: 600; color: #fff;">Included Memories</span>
|
|
||||||
`
|
|
||||||
|
|
||||||
const content = document.createElement("div")
|
|
||||||
content.style.cssText = `
|
|
||||||
padding: 0;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
`
|
|
||||||
|
|
||||||
const individualMemories = parseMemoriesFromDataset(
|
|
||||||
iconElement.dataset.memoriesData,
|
|
||||||
)
|
|
||||||
|
|
||||||
individualMemories.forEach((memory, index) => {
|
|
||||||
const memoryItem = document.createElement("div")
|
|
||||||
memoryItem.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
`
|
|
||||||
|
|
||||||
const memoryText = document.createElement("div")
|
|
||||||
memoryText.style.cssText = `
|
|
||||||
flex: 1;
|
|
||||||
color: #e5e5e5;
|
|
||||||
`
|
|
||||||
memoryText.textContent = memory.trim()
|
|
||||||
|
|
||||||
const removeBtn = document.createElement("button")
|
|
||||||
removeBtn.style.cssText = `
|
|
||||||
background: transparent;
|
|
||||||
color: #9ca3af;
|
|
||||||
border: none;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: fit-content;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
`
|
|
||||||
removeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`
|
|
||||||
removeBtn.dataset.memoryIndex = index.toString()
|
|
||||||
|
|
||||||
removeBtn.addEventListener("mouseenter", () => {
|
|
||||||
removeBtn.style.color = "#ef4444"
|
|
||||||
})
|
|
||||||
removeBtn.addEventListener("mouseleave", () => {
|
|
||||||
removeBtn.style.color = "#9ca3af"
|
|
||||||
})
|
|
||||||
|
|
||||||
memoryItem.appendChild(memoryText)
|
|
||||||
memoryItem.appendChild(removeBtn)
|
|
||||||
content.appendChild(memoryItem)
|
|
||||||
})
|
|
||||||
|
|
||||||
popup.appendChild(header)
|
|
||||||
popup.appendChild(content)
|
|
||||||
document.body.appendChild(popup)
|
|
||||||
|
|
||||||
feedbackDiv.addEventListener("mouseenter", () => {
|
|
||||||
const textSpan = feedbackDiv.querySelector("span:last-child")
|
|
||||||
if (textSpan) {
|
|
||||||
textSpan.textContent = "Click to see memories"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
feedbackDiv.addEventListener("mouseleave", () => {
|
|
||||||
const textSpan = feedbackDiv.querySelector("span:last-child")
|
|
||||||
if (textSpan) {
|
|
||||||
textSpan.textContent = "Included Memories"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
feedbackDiv.addEventListener("click", (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
popup.style.display = "block"
|
|
||||||
})
|
|
||||||
|
|
||||||
const onClick = (e: MouseEvent) => {
|
|
||||||
if (!popup.contains(e.target as Node)) {
|
|
||||||
popup.style.display = "none"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("click", onClick)
|
|
||||||
t3IncludedPopup = {
|
|
||||||
el: popup,
|
|
||||||
onClick,
|
|
||||||
timer: setTimeout(disposeT3IncludedPopup, 300000),
|
|
||||||
}
|
|
||||||
|
|
||||||
content.querySelectorAll("button[data-memory-index]").forEach((button) => {
|
|
||||||
const htmlButton = button as HTMLButtonElement
|
|
||||||
htmlButton.addEventListener("click", () => {
|
|
||||||
const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10)
|
|
||||||
htmlButton.parentElement?.remove()
|
|
||||||
|
|
||||||
const remainingMemories = parseMemoriesFromDataset(
|
|
||||||
iconElement.dataset.memoriesData,
|
|
||||||
)
|
|
||||||
remainingMemories.splice(index, 1)
|
|
||||||
const remaining = renumberIncludedMemories(remainingMemories)
|
|
||||||
|
|
||||||
const textareaElement =
|
|
||||||
(document.querySelector("textarea") as HTMLTextAreaElement) ||
|
|
||||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
|
||||||
|
|
||||||
// Only wipe when nothing remains — `<= 1` used to discard the last kept memory.
|
|
||||||
if (remaining.length === 0) {
|
|
||||||
if (textareaElement?.dataset.supermemories) {
|
|
||||||
delete textareaElement.dataset.supermemories
|
|
||||||
}
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
disposeT3IncludedPopup()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
iconElement.dataset.memoriesData =
|
|
||||||
serializeMemoriesForDataset(remaining)
|
|
||||||
if (textareaElement) {
|
|
||||||
textareaElement.dataset.supermemories =
|
|
||||||
buildSupermemoryText(remaining)
|
|
||||||
}
|
|
||||||
|
|
||||||
content
|
|
||||||
.querySelectorAll("button[data-memory-index]")
|
|
||||||
.forEach((btn, newIndex) => {
|
|
||||||
const htmlBtn = btn as HTMLButtonElement
|
|
||||||
htmlBtn.dataset.memoryIndex = String(newIndex)
|
|
||||||
const label = htmlBtn.previousElementSibling
|
|
||||||
if (label) {
|
|
||||||
label.textContent = remaining[newIndex].trim()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
iconElement.innerHTML = ""
|
|
||||||
iconElement.appendChild(feedbackDiv)
|
|
||||||
|
|
||||||
if (resetAfter > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
}, resetAfter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupT3PromptCapture() {
|
|
||||||
if (document.body.hasAttribute("data-t3-prompt-capture-setup")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
document.body.setAttribute("data-t3-prompt-capture-setup", "true")
|
|
||||||
|
|
||||||
const captureT3PromptContent = async (_source: string) => {
|
|
||||||
const autoCapture = (await autoCapturePromptsEnabled.getValue()) ?? false
|
|
||||||
|
|
||||||
if (!autoCapture) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let promptContent = ""
|
|
||||||
|
|
||||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement
|
|
||||||
if (textarea) {
|
|
||||||
promptContent = textarea.value || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!promptContent) {
|
|
||||||
const contentEditableDiv = document.querySelector(
|
|
||||||
'div[contenteditable="true"]',
|
|
||||||
) as HTMLElement
|
|
||||||
if (contentEditableDiv) {
|
|
||||||
promptContent =
|
|
||||||
contentEditableDiv.textContent || contentEditableDiv.innerText || ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textareaElement =
|
|
||||||
textarea ||
|
|
||||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
|
||||||
const storedMemories = textareaElement?.dataset.supermemories
|
|
||||||
if (
|
|
||||||
storedMemories &&
|
|
||||||
textareaElement &&
|
|
||||||
!promptContent.includes("Supermemories of user")
|
|
||||||
) {
|
|
||||||
if (textareaElement.tagName === "TEXTAREA") {
|
|
||||||
;(textareaElement as HTMLTextAreaElement).value =
|
|
||||||
`${promptContent} ${storedMemories}`
|
|
||||||
promptContent = (textareaElement as HTMLTextAreaElement).value
|
|
||||||
} else {
|
|
||||||
textareaElement.appendChild(document.createTextNode(storedMemories))
|
|
||||||
promptContent =
|
|
||||||
textareaElement.textContent || textareaElement.innerText || ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (promptContent.trim()) {
|
|
||||||
try {
|
|
||||||
await browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.CAPTURE_PROMPT,
|
|
||||||
data: {
|
|
||||||
prompt: promptContent,
|
|
||||||
platform: "t3",
|
|
||||||
source: window.location.href,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sending T3 prompt to background:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons = document.querySelectorAll('[id*="sm-t3-input-bar-element"]')
|
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
if (iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (textareaElement?.dataset.supermemories) {
|
|
||||||
delete textareaElement.dataset.supermemories
|
|
||||||
}
|
|
||||||
disposeT3IncludedPopup()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleT3SendButtonClick = async (event: Event) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
const sendButton =
|
|
||||||
target.closest("button.focus-visible\\:ring-ring") ||
|
|
||||||
target.closest('button[class*="bg-[rgb(162,59,103)]"]') ||
|
|
||||||
target.closest('button[class*="rounded-lg"]')
|
|
||||||
|
|
||||||
if (sendButton) {
|
|
||||||
const textareaElement =
|
|
||||||
(document.querySelector("textarea") as HTMLTextAreaElement) ||
|
|
||||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
|
||||||
|
|
||||||
const hasMemories =
|
|
||||||
textareaElement?.dataset.supermemories ||
|
|
||||||
(
|
|
||||||
document.querySelector(
|
|
||||||
'[id*="sm-t3-input-bar-element"]',
|
|
||||||
) as HTMLElement
|
|
||||||
)?.dataset.memoriesData
|
|
||||||
|
|
||||||
if (!hasMemories) {
|
|
||||||
return // No memories present, let the button click proceed normally
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
await captureT3PromptContent("button click")
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const form = sendButton.closest("form")
|
|
||||||
if (form) {
|
|
||||||
form.requestSubmit()
|
|
||||||
} else {
|
|
||||||
const newEvent = new MouseEvent("click", {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
view: window,
|
|
||||||
})
|
|
||||||
document.removeEventListener("click", handleT3SendButtonClick, true)
|
|
||||||
sendButton.dispatchEvent(newEvent)
|
|
||||||
setTimeout(() => {
|
|
||||||
document.addEventListener("click", handleT3SendButtonClick, true)
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleT3EnterKey = async (event: KeyboardEvent) => {
|
|
||||||
const target = event.target as HTMLElement
|
|
||||||
|
|
||||||
if (
|
|
||||||
(target.matches("textarea") ||
|
|
||||||
target.matches('div[contenteditable="true"]')) &&
|
|
||||||
event.key === "Enter" &&
|
|
||||||
!event.shiftKey
|
|
||||||
) {
|
|
||||||
const textareaElement =
|
|
||||||
(document.querySelector("textarea") as HTMLTextAreaElement) ||
|
|
||||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
|
||||||
|
|
||||||
const hasMemories =
|
|
||||||
textareaElement?.dataset.supermemories ||
|
|
||||||
(
|
|
||||||
document.querySelector(
|
|
||||||
'[id*="sm-t3-input-bar-element"]',
|
|
||||||
) as HTMLElement
|
|
||||||
)?.dataset.memoriesData
|
|
||||||
|
|
||||||
if (!hasMemories) {
|
|
||||||
return // No memories present, let the Enter key proceed normally
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
await captureT3PromptContent("Enter key")
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const form = target.closest("form")
|
|
||||||
if (form) {
|
|
||||||
form.requestSubmit()
|
|
||||||
} else {
|
|
||||||
const newEvent = new KeyboardEvent("keydown", {
|
|
||||||
key: "Enter",
|
|
||||||
code: "Enter",
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
})
|
|
||||||
target.dispatchEvent(newEvent)
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("click", handleT3SendButtonClick, true)
|
|
||||||
document.addEventListener("keydown", handleT3EnterKey, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupT3AutoFetch() {
|
|
||||||
const autoSearch = (await autoSearchEnabled.getValue()) ?? false
|
|
||||||
|
|
||||||
if (!autoSearch) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const textareaElement =
|
|
||||||
(document.querySelector("textarea") as HTMLTextAreaElement) ||
|
|
||||||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
|
|
||||||
|
|
||||||
if (
|
|
||||||
!textareaElement ||
|
|
||||||
textareaElement.hasAttribute("data-supermemory-auto-fetch")
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
textareaElement.setAttribute("data-supermemory-auto-fetch", "true")
|
|
||||||
|
|
||||||
const handleInput = () => {
|
|
||||||
if (t3DebounceTimeout) {
|
|
||||||
clearTimeout(t3DebounceTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
t3DebounceTimeout = setTimeout(async () => {
|
|
||||||
let content = ""
|
|
||||||
if (textareaElement.tagName === "TEXTAREA") {
|
|
||||||
content = (textareaElement as HTMLTextAreaElement).value?.trim() || ""
|
|
||||||
} else {
|
|
||||||
content = textareaElement.textContent?.trim() || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content.length > 2) {
|
|
||||||
await getRelatedMemoriesForT3(
|
|
||||||
POSTHOG_EVENT_KEY.T3_CHAT_MEMORIES_AUTO_SEARCHED,
|
|
||||||
)
|
|
||||||
} else if (content.length === 0) {
|
|
||||||
const icons = document.querySelectorAll(
|
|
||||||
'[id*="sm-t3-input-bar-element"]',
|
|
||||||
)
|
|
||||||
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
const iconElement = icon as HTMLElement
|
|
||||||
if (iconElement.dataset.originalHtml) {
|
|
||||||
iconElement.innerHTML = iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.originalHtml
|
|
||||||
delete iconElement.dataset.memoriesData
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (textareaElement.dataset.supermemories) {
|
|
||||||
delete textareaElement.dataset.supermemories
|
|
||||||
}
|
|
||||||
disposeT3IncludedPopup()
|
|
||||||
}
|
|
||||||
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
|
|
||||||
}
|
|
||||||
|
|
||||||
textareaElement.addEventListener("input", handleInput)
|
|
||||||
}
|
|
||||||
|
|
@ -1,739 +0,0 @@
|
||||||
import {
|
|
||||||
DOMAINS,
|
|
||||||
ELEMENT_IDS,
|
|
||||||
MESSAGE_TYPES,
|
|
||||||
POSTHOG_EVENT_KEY,
|
|
||||||
STORAGE_KEYS,
|
|
||||||
UI_CONFIG,
|
|
||||||
} from "../../utils/constants"
|
|
||||||
import { trackEvent } from "../../utils/posthog"
|
|
||||||
import {
|
|
||||||
createProjectSelectionModal,
|
|
||||||
createSaveTweetElement,
|
|
||||||
DOMUtils,
|
|
||||||
} from "../../utils/ui-components"
|
|
||||||
|
|
||||||
async function loadSpaceGroteskFonts(): Promise<void> {
|
|
||||||
if (document.getElementById("supermemory-modal-styles")) {
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
const style = document.createElement("style")
|
|
||||||
style.id = "supermemory-modal-styles"
|
|
||||||
style.textContent = `
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap');
|
|
||||||
`
|
|
||||||
document.head.appendChild(style)
|
|
||||||
|
|
||||||
await document.fonts.ready
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if import intent is valid (exists and not expired)
|
|
||||||
*/
|
|
||||||
async function checkAndConsumeImportIntent(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const result = await browser.storage.local.get(
|
|
||||||
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL,
|
|
||||||
)
|
|
||||||
const intentUntil = result[
|
|
||||||
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL
|
|
||||||
] as number | undefined
|
|
||||||
|
|
||||||
if (intentUntil && Date.now() < intentUntil) {
|
|
||||||
await browser.storage.local.remove(
|
|
||||||
STORAGE_KEYS.TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL,
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking import intent:", error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if onboarding toast has been shown before
|
|
||||||
*/
|
|
||||||
async function hasOnboardingBeenShown(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const result = await browser.storage.local.get(
|
|
||||||
STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN,
|
|
||||||
)
|
|
||||||
return !!result[STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN]
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking onboarding status:", error)
|
|
||||||
return true // Default to true to avoid showing toast on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark onboarding toast as shown
|
|
||||||
*/
|
|
||||||
async function markOnboardingAsShown(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await browser.storage.local.set({
|
|
||||||
[STORAGE_KEYS.TWITTER_BOOKMARKS_ONBOARDING_SEEN]: true,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error marking onboarding as shown:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initializeTwitter() {
|
|
||||||
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.location.pathname === "/i/bookmarks") {
|
|
||||||
setTimeout(async () => {
|
|
||||||
if (window.location.pathname === "/i/bookmarks") {
|
|
||||||
await handleBookmarksPageLoad()
|
|
||||||
}
|
|
||||||
}, 2000)
|
|
||||||
} else {
|
|
||||||
// Clean up any injected UI if navigating away
|
|
||||||
removeAllTwitterUI()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle what to show when user lands on bookmarks page
|
|
||||||
*/
|
|
||||||
async function handleBookmarksPageLoad() {
|
|
||||||
if (window.location.pathname !== "/i/bookmarks") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addTwitterImportButtonForFolders() // Add buttons to bookmark folders
|
|
||||||
|
|
||||||
const hasIntent = await checkAndConsumeImportIntent()
|
|
||||||
|
|
||||||
if (hasIntent) {
|
|
||||||
await openImportModal()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const onboardingShown = await hasOnboardingBeenShown()
|
|
||||||
|
|
||||||
if (!onboardingShown) {
|
|
||||||
await showOnboardingToast()
|
|
||||||
await markOnboardingAsShown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the import modal and handles the import flow
|
|
||||||
*/
|
|
||||||
export async function openImportModal() {
|
|
||||||
try {
|
|
||||||
const response = await browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.FETCH_PROJECTS,
|
|
||||||
})
|
|
||||||
|
|
||||||
const projects = response.success && response.data ? response.data : []
|
|
||||||
|
|
||||||
if (projects.length === 0) {
|
|
||||||
await browser.runtime.sendMessage({
|
|
||||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
|
||||||
})
|
|
||||||
await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, {
|
|
||||||
source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
await showAllBookmarksProjectModal(projects)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error opening import modal:", error)
|
|
||||||
await browser.runtime.sendMessage({
|
|
||||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showAllBookmarksProjectModal(
|
|
||||||
projects: Array<{ id: string; name: string; containerTag: string }>,
|
|
||||||
) {
|
|
||||||
await loadSpaceGroteskFonts()
|
|
||||||
|
|
||||||
const modal = createProjectSelectionModal(
|
|
||||||
projects,
|
|
||||||
async (selectedProject) => {
|
|
||||||
modal.remove()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await browser.runtime.sendMessage({
|
|
||||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
|
||||||
selectedProject: selectedProject,
|
|
||||||
})
|
|
||||||
await trackEvent(POSTHOG_EVENT_KEY.TWITTER_IMPORT_STARTED, {
|
|
||||||
source: `${POSTHOG_EVENT_KEY.SOURCE}_content_script`,
|
|
||||||
project_selected: true,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error importing all bookmarks:", error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
modal.remove()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
document.body.appendChild(modal)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the one-time onboarding toast with progress bar
|
|
||||||
*/
|
|
||||||
async function showOnboardingToast() {
|
|
||||||
await loadSpaceGroteskFonts()
|
|
||||||
|
|
||||||
// Remove any existing toast
|
|
||||||
const existingToast = document.getElementById(
|
|
||||||
ELEMENT_IDS.TWITTER_ONBOARDING_TOAST,
|
|
||||||
)
|
|
||||||
if (existingToast) {
|
|
||||||
existingToast.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = UI_CONFIG.ONBOARDING_TOAST_DURATION
|
|
||||||
|
|
||||||
// Create toast container
|
|
||||||
const toast = document.createElement("div")
|
|
||||||
toast.id = ELEMENT_IDS.TWITTER_ONBOARDING_TOAST
|
|
||||||
toast.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 2147483647;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #374151;
|
|
||||||
min-width: 320px;
|
|
||||||
max-width: 380px;
|
|
||||||
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
|
|
||||||
animation: smSlideInUp 0.3s ease-out;
|
|
||||||
overflow: hidden;
|
|
||||||
`
|
|
||||||
|
|
||||||
// Add keyframe animations if not already present
|
|
||||||
if (!document.getElementById("supermemory-onboarding-toast-styles")) {
|
|
||||||
const style = document.createElement("style")
|
|
||||||
style.id = "supermemory-onboarding-toast-styles"
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes smSlideInUp {
|
|
||||||
from { transform: translateY(100%); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes smFadeOut {
|
|
||||||
from { transform: translateY(0); opacity: 1; }
|
|
||||||
to { transform: translateY(100%); opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes smProgressGrow {
|
|
||||||
from { transform: scaleX(0); }
|
|
||||||
to { transform: scaleX(1); }
|
|
||||||
}
|
|
||||||
@keyframes smPulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
`
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header with icon, text and close button
|
|
||||||
const header = document.createElement("div")
|
|
||||||
header.style.cssText =
|
|
||||||
"display: flex; align-items: flex-start; gap: 12px; position: relative;"
|
|
||||||
|
|
||||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
|
||||||
const icon = document.createElement("img")
|
|
||||||
icon.src = iconUrl
|
|
||||||
icon.alt = "Supermemory"
|
|
||||||
icon.style.cssText =
|
|
||||||
"width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; margin-top: 2px;"
|
|
||||||
|
|
||||||
const textContainer = document.createElement("div")
|
|
||||||
textContainer.style.cssText =
|
|
||||||
"display: flex; flex-direction: column; gap: 4px; flex: 1;"
|
|
||||||
|
|
||||||
const title = document.createElement("span")
|
|
||||||
title.style.cssText = "font-weight: 600; font-size: 14px; color: #111827;"
|
|
||||||
title.textContent = "Import X/Twitter Bookmarks"
|
|
||||||
|
|
||||||
const description = document.createElement("span")
|
|
||||||
description.style.cssText =
|
|
||||||
"font-size: 13px; color: #6b7280; line-height: 1.4;"
|
|
||||||
description.textContent =
|
|
||||||
"You can import all your Twitter bookmarks to Supermemory with one click."
|
|
||||||
|
|
||||||
textContainer.appendChild(title)
|
|
||||||
textContainer.appendChild(description)
|
|
||||||
|
|
||||||
// Close button
|
|
||||||
const closeButton = document.createElement("button")
|
|
||||||
closeButton.setAttribute("aria-label", "Close onboarding toast")
|
|
||||||
closeButton.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
color: #9ca3af;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
`
|
|
||||||
closeButton.innerHTML = `
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
closeButton.addEventListener("mouseenter", () => {
|
|
||||||
closeButton.style.backgroundColor = "#f3f4f6"
|
|
||||||
})
|
|
||||||
closeButton.addEventListener("mouseleave", () => {
|
|
||||||
closeButton.style.backgroundColor = "transparent"
|
|
||||||
})
|
|
||||||
closeButton.addEventListener("click", () => {
|
|
||||||
dismissToast(toast)
|
|
||||||
})
|
|
||||||
|
|
||||||
header.appendChild(icon)
|
|
||||||
header.appendChild(textContainer)
|
|
||||||
header.appendChild(closeButton)
|
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
const buttonsContainer = document.createElement("div")
|
|
||||||
buttonsContainer.style.cssText = "display: flex; gap: 8px; margin-top: 4px;"
|
|
||||||
|
|
||||||
const importButton = document.createElement("button")
|
|
||||||
importButton.style.cssText = `
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%);
|
|
||||||
color: white;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
font-family: inherit;
|
|
||||||
`
|
|
||||||
importButton.textContent = "Import now"
|
|
||||||
importButton.addEventListener("mouseenter", () => {
|
|
||||||
importButton.style.opacity = "0.9"
|
|
||||||
})
|
|
||||||
importButton.addEventListener("mouseleave", () => {
|
|
||||||
importButton.style.opacity = "1"
|
|
||||||
})
|
|
||||||
importButton.addEventListener("click", async () => {
|
|
||||||
dismissToast(toast)
|
|
||||||
await openImportModal()
|
|
||||||
})
|
|
||||||
|
|
||||||
const learnMoreButton = document.createElement("button")
|
|
||||||
learnMoreButton.style.cssText = `
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
font-family: inherit;
|
|
||||||
`
|
|
||||||
learnMoreButton.textContent = "Learn more"
|
|
||||||
learnMoreButton.addEventListener("mouseenter", () => {
|
|
||||||
learnMoreButton.style.backgroundColor = "#f9fafb"
|
|
||||||
})
|
|
||||||
learnMoreButton.addEventListener("mouseleave", () => {
|
|
||||||
learnMoreButton.style.backgroundColor = "transparent"
|
|
||||||
})
|
|
||||||
learnMoreButton.addEventListener("click", () => {
|
|
||||||
window.open("https://docs.supermemory.ai/connectors/twitter", "_blank")
|
|
||||||
})
|
|
||||||
|
|
||||||
buttonsContainer.appendChild(importButton)
|
|
||||||
buttonsContainer.appendChild(learnMoreButton)
|
|
||||||
|
|
||||||
// Progress bar container
|
|
||||||
const progressBarContainer = document.createElement("div")
|
|
||||||
progressBarContainer.setAttribute("role", "progressbar")
|
|
||||||
progressBarContainer.setAttribute("aria-valuemin", "0")
|
|
||||||
progressBarContainer.setAttribute("aria-valuemax", "100")
|
|
||||||
progressBarContainer.setAttribute("aria-valuenow", "0")
|
|
||||||
progressBarContainer.setAttribute(
|
|
||||||
"aria-label",
|
|
||||||
"Onboarding toast auto-dismiss progress",
|
|
||||||
)
|
|
||||||
progressBarContainer.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
background: #e5e7eb;
|
|
||||||
`
|
|
||||||
|
|
||||||
const progressBar = document.createElement("div")
|
|
||||||
progressBar.style.cssText = `
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #0ff0d2, #5bd3fb, #1e0ff0);
|
|
||||||
transform-origin: left;
|
|
||||||
animation: smProgressGrow ${duration}ms linear forwards;
|
|
||||||
`
|
|
||||||
|
|
||||||
// Update progress bar ARIA value as animation progresses
|
|
||||||
const startTime = Date.now()
|
|
||||||
const updateProgress = () => {
|
|
||||||
const elapsed = Date.now() - startTime
|
|
||||||
const progress = Math.min(100, Math.round((elapsed / duration) * 100))
|
|
||||||
progressBarContainer.setAttribute("aria-valuenow", String(progress))
|
|
||||||
if (progress < 100) {
|
|
||||||
requestAnimationFrame(updateProgress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestAnimationFrame(updateProgress)
|
|
||||||
|
|
||||||
progressBarContainer.appendChild(progressBar)
|
|
||||||
|
|
||||||
// Assemble toast
|
|
||||||
toast.appendChild(header)
|
|
||||||
toast.appendChild(buttonsContainer)
|
|
||||||
toast.appendChild(progressBarContainer)
|
|
||||||
|
|
||||||
document.body.appendChild(toast)
|
|
||||||
|
|
||||||
// Auto-dismiss after duration
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(toast)) {
|
|
||||||
dismissToast(toast)
|
|
||||||
}
|
|
||||||
}, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismiss the toast with animation
|
|
||||||
*/
|
|
||||||
function dismissToast(toast: HTMLElement) {
|
|
||||||
toast.style.animation = "smFadeOut 0.3s ease-out forwards"
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(toast)) {
|
|
||||||
toast.remove()
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all Twitter-specific injected UI
|
|
||||||
*/
|
|
||||||
function removeAllTwitterUI() {
|
|
||||||
// Remove import button (legacy)
|
|
||||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) {
|
|
||||||
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)
|
|
||||||
}
|
|
||||||
// Remove onboarding toast
|
|
||||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)) {
|
|
||||||
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_ONBOARDING_TOAST)
|
|
||||||
}
|
|
||||||
// Remove import progress toast
|
|
||||||
if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)) {
|
|
||||||
DOMUtils.removeElement(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
|
|
||||||
}
|
|
||||||
// Remove any folder buttons
|
|
||||||
document.querySelectorAll("[data-supermemory-button]").forEach((button) => {
|
|
||||||
button.remove()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows or updates the import progress toast in the bottom-right
|
|
||||||
*/
|
|
||||||
function showOrUpdateImportProgressToast(message: string, isComplete = false) {
|
|
||||||
let toast = document.getElementById(ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST)
|
|
||||||
|
|
||||||
if (!toast) {
|
|
||||||
// Ensure animation styles are available
|
|
||||||
if (!document.getElementById("supermemory-onboarding-toast-styles")) {
|
|
||||||
const style = document.createElement("style")
|
|
||||||
style.id = "supermemory-onboarding-toast-styles"
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes smSlideInUp {
|
|
||||||
from { transform: translateY(100%); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes smFadeOut {
|
|
||||||
from { transform: translateY(0); opacity: 1; }
|
|
||||||
to { transform: translateY(100%); opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes smPulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.4; }
|
|
||||||
}
|
|
||||||
`
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new toast
|
|
||||||
toast = document.createElement("div")
|
|
||||||
toast.id = ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST
|
|
||||||
toast.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 2147483647;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #374151;
|
|
||||||
min-width: 280px;
|
|
||||||
max-width: 360px;
|
|
||||||
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
|
|
||||||
animation: smSlideInUp 0.3s ease-out;
|
|
||||||
`
|
|
||||||
|
|
||||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
|
||||||
const icon = document.createElement("img")
|
|
||||||
icon.src = iconUrl
|
|
||||||
icon.alt = "Supermemory"
|
|
||||||
icon.id = "sm-import-progress-icon"
|
|
||||||
icon.style.cssText =
|
|
||||||
"width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; animation: smPulse 1.5s ease-in-out infinite;"
|
|
||||||
|
|
||||||
const textSpan = document.createElement("span")
|
|
||||||
textSpan.id = "sm-import-progress-text"
|
|
||||||
textSpan.style.cssText = "font-weight: 500; flex: 1;"
|
|
||||||
textSpan.textContent = message
|
|
||||||
|
|
||||||
toast.appendChild(icon)
|
|
||||||
toast.appendChild(textSpan)
|
|
||||||
document.body.appendChild(toast)
|
|
||||||
} else {
|
|
||||||
// Update existing toast
|
|
||||||
const textSpan = toast.querySelector(
|
|
||||||
"#sm-import-progress-text",
|
|
||||||
) as HTMLSpanElement
|
|
||||||
if (textSpan) {
|
|
||||||
textSpan.textContent = message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Style for completion
|
|
||||||
if (isComplete) {
|
|
||||||
const icon = toast.querySelector(
|
|
||||||
"#sm-import-progress-icon",
|
|
||||||
) as HTMLImageElement
|
|
||||||
if (icon) {
|
|
||||||
icon.style.animation = "none"
|
|
||||||
icon.style.opacity = "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
const textSpan = toast.querySelector(
|
|
||||||
"#sm-import-progress-text",
|
|
||||||
) as HTMLSpanElement
|
|
||||||
if (textSpan) {
|
|
||||||
textSpan.style.color = "#059669"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-dismiss after 4 seconds on completion
|
|
||||||
setTimeout(() => {
|
|
||||||
const existingToast = document.getElementById(
|
|
||||||
ELEMENT_IDS.TWITTER_IMPORT_PROGRESS_TOAST,
|
|
||||||
)
|
|
||||||
if (existingToast) {
|
|
||||||
dismissToast(existingToast)
|
|
||||||
}
|
|
||||||
}, 4000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTwitterImportUI(message: {
|
|
||||||
type: string
|
|
||||||
importedMessage?: string
|
|
||||||
totalImported?: number
|
|
||||||
}) {
|
|
||||||
if (message.type === MESSAGE_TYPES.IMPORT_UPDATE && message.importedMessage) {
|
|
||||||
showOrUpdateImportProgressToast(message.importedMessage, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === MESSAGE_TYPES.IMPORT_DONE) {
|
|
||||||
showOrUpdateImportProgressToast(
|
|
||||||
`✓ Imported ${message.totalImported} tweets!`,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleTwitterNavigation() {
|
|
||||||
if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.location.pathname === "/i/bookmarks") {
|
|
||||||
addTwitterImportButtonForFolders()
|
|
||||||
await handleBookmarksPageLoad()
|
|
||||||
} else {
|
|
||||||
removeAllTwitterUI()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds import buttons to bookmark folders
|
|
||||||
*/
|
|
||||||
function addTwitterImportButtonForFolders() {
|
|
||||||
if (window.location.pathname !== "/i/bookmarks") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetElements = document.querySelectorAll(
|
|
||||||
".css-175oi2r.r-1wtj0ep.r-16x9es5.r-1mmae3n.r-o7ynqc.r-6416eg.r-1ny4l3l.r-1loqt21",
|
|
||||||
)
|
|
||||||
|
|
||||||
targetElements.forEach((element) => {
|
|
||||||
addButtonToElement(element as HTMLElement)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds an import button to a bookmark folder element
|
|
||||||
*/
|
|
||||||
function addButtonToElement(element: HTMLElement) {
|
|
||||||
if (element.querySelector("[data-supermemory-button]")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSpaceGroteskFonts()
|
|
||||||
|
|
||||||
const button = createSaveTweetElement(async () => {
|
|
||||||
const url = element.getAttribute("href")
|
|
||||||
const bookmarkCollectionId = url?.split("/").pop()
|
|
||||||
if (bookmarkCollectionId) {
|
|
||||||
await showFolderProjectSelectionModal(bookmarkCollectionId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
button.setAttribute("data-supermemory-button", "true")
|
|
||||||
|
|
||||||
element.appendChild(button)
|
|
||||||
element.style.flexDirection = "row"
|
|
||||||
element.style.alignItems = "center"
|
|
||||||
element.style.justifyContent = "center"
|
|
||||||
element.style.gap = "10px"
|
|
||||||
element.style.padding = "10px"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows the project selection modal for folder imports
|
|
||||||
*/
|
|
||||||
async function showFolderProjectSelectionModal(bookmarkCollectionId: string) {
|
|
||||||
await loadSpaceGroteskFonts()
|
|
||||||
|
|
||||||
const modal = createProjectSelectionModal(
|
|
||||||
[],
|
|
||||||
async (selectedProject) => {
|
|
||||||
modal.remove()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await browser.runtime.sendMessage({
|
|
||||||
type: MESSAGE_TYPES.BATCH_IMPORT_ALL,
|
|
||||||
isFolderImport: true,
|
|
||||||
bookmarkCollectionId: bookmarkCollectionId,
|
|
||||||
selectedProject: selectedProject,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error importing bookmarks:", error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
modal.remove()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
document.body.appendChild(modal)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await browser.runtime.sendMessage({
|
|
||||||
action: MESSAGE_TYPES.FETCH_PROJECTS,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
const projects = response.data
|
|
||||||
updateModalWithProjects(modal, projects)
|
|
||||||
} else {
|
|
||||||
console.error("Failed to fetch projects:", response.error)
|
|
||||||
updateModalWithProjects(modal, [])
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching projects:", error)
|
|
||||||
updateModalWithProjects(modal, [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the modal with fetched projects
|
|
||||||
*/
|
|
||||||
function updateModalWithProjects(
|
|
||||||
modal: HTMLElement,
|
|
||||||
projects: Array<{ id: string; name: string; containerTag: string }>,
|
|
||||||
) {
|
|
||||||
const select = modal.querySelector("#project-select") as HTMLSelectElement
|
|
||||||
if (!select) return
|
|
||||||
|
|
||||||
while (select.children.length > 1) {
|
|
||||||
select.removeChild(select.children[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projects.length === 0) {
|
|
||||||
const noProjectsOption = document.createElement("option")
|
|
||||||
noProjectsOption.value = ""
|
|
||||||
noProjectsOption.textContent = "No projects available"
|
|
||||||
noProjectsOption.disabled = true
|
|
||||||
select.appendChild(noProjectsOption)
|
|
||||||
|
|
||||||
const importButton = modal.querySelector(
|
|
||||||
"button:last-child",
|
|
||||||
) as HTMLButtonElement
|
|
||||||
if (importButton) {
|
|
||||||
importButton.disabled = true
|
|
||||||
importButton.style.cssText = `
|
|
||||||
padding: 10px 16px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
`
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
projects.forEach((project) => {
|
|
||||||
const option = document.createElement("option")
|
|
||||||
option.value = project.id
|
|
||||||
option.textContent = project.name
|
|
||||||
option.dataset.containerTag = project.containerTag
|
|
||||||
select.appendChild(option)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
/* Custom Font Definitions */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-Light.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-SemiBold.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>supermemory</title>
|
|
||||||
<meta name="manifest.type" content="browser_action" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { QueryClientProvider } from "@tanstack/react-query"
|
|
||||||
import React from "react"
|
|
||||||
import ReactDOM from "react-dom/client"
|
|
||||||
import { queryClient } from "../../utils/query-client"
|
|
||||||
import App from "./App.js"
|
|
||||||
import "./style.css"
|
|
||||||
|
|
||||||
const rootElement = document.getElementById("root")
|
|
||||||
if (rootElement) {
|
|
||||||
ReactDOM.createRoot(rootElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
:root {
|
|
||||||
font-family:
|
|
||||||
"Space Grotesk", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: dark;
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
background-color: #0a0e14;
|
|
||||||
border: 1px solid #0a0e14;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import { getSupermemoryLoginUrl } from "../../utils/constants"
|
|
||||||
|
|
||||||
const featureCards = [
|
|
||||||
{
|
|
||||||
number: "01",
|
|
||||||
title: "Save any page",
|
|
||||||
description: "Articles, docs, and references from the browser.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "02",
|
|
||||||
title: "Import X bookmarks",
|
|
||||||
description: "Bring saved posts into your memory library.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "03",
|
|
||||||
title: "Capture AI chats",
|
|
||||||
description: "Save useful conversations from ChatGPT, Claude, and Gemini.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "04",
|
|
||||||
title: "Use context anywhere",
|
|
||||||
description: "Search and reuse memories when you need them.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function Welcome() {
|
|
||||||
return (
|
|
||||||
<div className="relative min-h-screen overflow-hidden bg-[#05080D] text-white font-[Space_Grotesk,-apple-system,BlinkMacSystemFont,Segoe_UI,Roboto,sans-serif]">
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(180deg, #05080D 0%, #05070A 48%, #060A18 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.20)_1px,transparent_1px)] bg-size-[32px_32px] opacity-70 mask-[linear-gradient(to_bottom,transparent_0%,black_12%,black_100%)]" />
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[55%] bg-[radial-gradient(ellipse_at_bottom,rgba(20,65,255,0.42),transparent_68%)]" />
|
|
||||||
|
|
||||||
<main className="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-6 sm:px-10">
|
|
||||||
<header className="flex items-center border-b border-white/10 pb-5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<img alt="" className="size-8 rounded-[4px]" src="./new_logo.png" />
|
|
||||||
<span className="text-lg font-semibold leading-none text-white">
|
|
||||||
supermemory
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="flex flex-1 flex-col items-center justify-center py-10 text-center">
|
|
||||||
<div className="mx-auto max-w-3xl">
|
|
||||||
<h1 className="text-4xl font-semibold leading-[1.05] tracking-normal text-white sm:text-6xl">
|
|
||||||
Your browser now has{" "}
|
|
||||||
<span className="text-[#369BFD]">supermemory.</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
|
|
||||||
<button
|
|
||||||
className="h-12 rounded-xl px-7 text-sm font-semibold text-white transition hover:brightness-110 focus:outline-none focus:ring-2 focus:ring-[#36fdfd]/70"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
|
|
||||||
boxShadow:
|
|
||||||
"1px 1px 2px 0px #1A88FF inset, 0 2px 18px 0 rgba(54, 155, 253, 0.24)",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
chrome.tabs.create({
|
|
||||||
url: getSupermemoryLoginUrl(),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Sign in to connect
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="h-12 rounded-xl border border-[#369BFD]/25 bg-[#080B0F]/80 px-6 text-sm font-semibold text-[#C7D7F2] transition hover:border-[#369BFD]/50 hover:bg-[#0D121A] focus:outline-none focus:ring-2 focus:ring-[#369BFD]/30"
|
|
||||||
onClick={() => {
|
|
||||||
chrome.tabs.create({
|
|
||||||
url: "https://supermemory.ai",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Open supermemory.ai
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-14 grid w-full max-w-5xl gap-3 text-left sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{featureCards.map((feature) => (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border border-white/10 bg-white/[0.035] p-4"
|
|
||||||
key={feature.number}
|
|
||||||
>
|
|
||||||
<p className="text-[11px] font-medium text-[#737373]">
|
|
||||||
{feature.number}
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-4 text-sm font-semibold text-white">
|
|
||||||
{feature.title}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[#A1A1AA]">
|
|
||||||
{feature.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer className="border-t border-white/10 py-5 text-xs text-[#737373]">
|
|
||||||
supermemory stores your extension session locally in Chrome.
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Welcome
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/png" href="/new_logo.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Welcome to supermemory</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { QueryClientProvider } from "@tanstack/react-query"
|
|
||||||
import React from "react"
|
|
||||||
import ReactDOM from "react-dom/client"
|
|
||||||
import { queryClient } from "../../utils/query-client"
|
|
||||||
import Welcome from "./Welcome"
|
|
||||||
import "./welcome.css"
|
|
||||||
|
|
||||||
const rootElement = document.getElementById("root")
|
|
||||||
if (rootElement) {
|
|
||||||
ReactDOM.createRoot(rootElement).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Welcome />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
/* Custom Font Definitions */
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-Light.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-SemiBold.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Space Grotesk";
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Global Styles */
|
|
||||||
body {
|
|
||||||
font-family:
|
|
||||||
"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
sans-serif;
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
{
|
|
||||||
"name": "supermemory-browser-extension",
|
|
||||||
"description": "Browser extension for the supermemory app",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "wxt --port 3001",
|
|
||||||
"dev:firefox": "wxt -b firefox",
|
|
||||||
"build": "wxt build",
|
|
||||||
"build:firefox": "wxt build -b firefox",
|
|
||||||
"check-types": "bun run compile",
|
|
||||||
"zip": "wxt zip",
|
|
||||||
"zip:firefox": "wxt zip -b firefox",
|
|
||||||
"compile": "tsc --noEmit",
|
|
||||||
"postinstall": "wxt prepare"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
|
||||||
"@tanstack/react-query": "^5.81.2",
|
|
||||||
"posthog-js": "^1.261.7",
|
|
||||||
"react": "19.2.2",
|
|
||||||
"react-dom": "19.2.2",
|
|
||||||
"tailwindcss": "^4.1.12",
|
|
||||||
"turndown": "^7.1.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/chrome": "^0.1.4",
|
|
||||||
"@types/react": "^19.1.2",
|
|
||||||
"@types/react-dom": "^19.1.3",
|
|
||||||
"@types/turndown": "^5.0.5",
|
|
||||||
"@wxt-dev/module-react": "^1.1.3",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"wxt": "^0.20.6"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 54 KiB |
|
|
@ -1,15 +0,0 @@
|
||||||
<svg width="2560" height="512" viewBox="0 0 2560 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M527.06 228.982H410.629V128H373.012V237.567C373.012 249.205 377.616 260.381 385.798 268.615L480.867 364.283L507.466 337.517L437.249 266.858H527.082V229.004L527.06 228.982Z" fill="#1C2026"/>
|
|
||||||
<path d="M232.948 174.504L303.164 245.163H213.332V283.017H329.763V383.999H367.38V274.432C367.38 262.795 362.776 251.618 354.594 243.384L259.546 147.738L232.948 174.504Z" fill="#1C2026"/>
|
|
||||||
<path d="M715.99 330.047C697.748 330.047 682.78 326.072 671.128 318.124C659.455 310.175 652.407 298.823 649.963 284.046L682.693 275.527C684.002 282.158 686.227 287.362 689.326 291.138C692.424 294.937 696.286 297.616 700.89 299.24C705.494 300.843 710.535 301.656 715.99 301.656C724.259 301.656 730.369 300.185 734.318 297.264C738.268 294.322 740.253 290.699 740.253 286.33C740.253 281.96 738.377 278.622 734.602 276.251C730.827 273.88 724.827 271.947 716.535 270.432L708.636 269.005C698.861 267.117 689.915 264.504 681.842 261.188C673.747 257.873 667.266 253.284 662.378 247.421C657.491 241.559 655.047 233.983 655.047 224.717C655.047 210.708 660.131 199.971 670.277 192.484C680.445 185.018 693.777 181.264 710.338 181.264C725.94 181.264 738.922 184.777 749.265 191.759C759.608 198.764 766.372 207.942 769.579 219.294L736.566 229.504C735.06 222.324 732.005 217.208 727.401 214.178C722.797 211.148 717.103 209.633 710.338 209.633C703.574 209.633 698.381 210.818 694.824 213.19C691.246 215.561 689.457 218.833 689.457 222.983C689.457 227.528 691.333 230.887 695.108 233.061C698.861 235.235 703.945 236.904 710.338 238.023L718.237 239.451C728.776 241.339 738.311 243.842 746.865 246.982C755.418 250.1 762.182 254.557 767.179 260.332C772.154 266.107 774.663 273.924 774.663 283.761C774.663 298.516 769.339 309.934 758.713 317.97C748.087 326.028 733.838 330.047 715.968 330.047H715.99Z" fill="#1C2026"/>
|
|
||||||
<path d="M836.632 329.474C825.722 329.474 816.187 326.971 808.004 321.943C799.822 316.937 793.472 309.976 788.956 301.084C784.439 292.191 782.191 281.959 782.191 270.431V186.4H817.736V267.599C817.736 278.204 820.311 286.153 825.504 291.444C830.675 296.736 838.05 299.393 847.651 299.393C858.561 299.393 867.027 295.748 873.049 288.458C879.071 281.168 882.083 271.002 882.083 257.937V186.4H917.627V327.213H882.65V308.769H877.566C875.318 313.511 871.085 318.144 864.867 322.69C858.67 327.235 849.244 329.496 836.654 329.496L836.632 329.474Z" fill="#1C2026"/>
|
|
||||||
<path d="M943.004 383.992V186.398H977.981V203.437H983.065C986.251 197.948 991.248 193.073 998.012 188.813C1004.78 184.554 1014.46 182.424 1027.08 182.424C1038.36 182.424 1048.81 185.212 1058.39 190.79C1067.99 196.367 1075.69 204.557 1081.52 215.338C1087.34 226.119 1090.27 239.184 1090.27 254.51V259.055C1090.27 274.381 1087.34 287.446 1081.52 298.227C1075.69 309.008 1067.97 317.198 1058.39 322.775C1048.79 328.352 1038.36 331.141 1027.08 331.141C1018.61 331.141 1011.52 330.153 1005.78 328.155C1000.04 326.179 995.437 323.61 991.946 320.492C988.455 317.374 985.684 314.212 983.632 310.984H978.548V383.948H943.004V383.992ZM1016.36 299.961C1027.47 299.961 1036.63 296.404 1043.88 289.312C1051.12 282.22 1054.74 271.856 1054.74 258.221V255.388C1054.74 241.753 1051.08 231.389 1043.75 224.296C1036.41 217.204 1027.29 213.647 1016.38 213.647C1005.47 213.647 996.353 217.204 989.022 224.296C981.691 231.389 978.025 241.753 978.025 255.388V258.221C978.025 271.856 981.691 282.22 989.022 289.312C996.353 296.404 1005.47 299.961 1016.38 299.961H1016.36Z" fill="#1C2026"/>
|
|
||||||
<path d="M1172.66 331.185C1158.74 331.185 1146.47 328.199 1135.85 322.248C1125.22 316.276 1116.95 307.866 1111.02 296.975C1105.1 286.084 1102.14 273.261 1102.14 258.506V255.103C1102.14 240.347 1105.04 227.524 1110.89 216.633C1116.71 205.743 1124.89 197.333 1135.43 191.36C1145.97 185.41 1158.19 182.424 1172.11 182.424C1186.03 182.424 1197.79 185.498 1207.94 191.646C1218.09 197.794 1226.01 206.313 1231.64 217.204C1237.29 228.095 1240.1 240.721 1240.1 255.103V267.311H1138.25C1138.62 276.972 1142.2 284.811 1148.96 290.871C1155.73 296.931 1164.02 299.961 1173.79 299.961C1183.57 299.961 1191.1 297.788 1195.79 293.44C1200.48 289.093 1204.06 284.262 1206.5 278.97L1235.57 294.296C1232.92 299.215 1229.13 304.573 1224.13 310.347C1219.13 316.122 1212.52 321.04 1204.23 325.103C1195.96 329.165 1185.42 331.207 1172.64 331.207L1172.66 331.185ZM1138.51 240.611H1203.97C1203.21 232.465 1199.98 225.943 1194.24 221.025C1188.5 216.106 1181.02 213.647 1171.81 213.647C1162.6 213.647 1154.59 216.106 1148.96 221.025C1143.31 225.943 1139.84 232.487 1138.53 240.611H1138.51Z" fill="#1C2026"/>
|
|
||||||
<path d="M1257.58 327.211V186.399H1292.56V202.296H1297.64C1299.71 196.609 1303.14 192.459 1307.94 189.802C1312.74 187.146 1318.33 185.828 1324.72 185.828H1341.65V217.622H1324.15C1315.12 217.622 1307.7 220.038 1301.87 224.868C1296.05 229.699 1293.12 237.12 1293.12 247.155V327.211H1257.58Z" fill="#1C2026"/>
|
|
||||||
<path d="M1355.18 327.213V186.4H1390.16V201.726H1395.24C1397.68 197.006 1401.72 192.878 1407.37 189.386C1413.02 185.895 1420.44 184.139 1429.65 184.139C1439.62 184.139 1447.61 186.071 1453.63 189.957C1459.65 193.844 1464.26 198.894 1467.46 205.152H1472.55C1475.73 199.092 1480.25 194.085 1486.1 190.111C1491.92 186.137 1500.19 184.161 1510.93 184.161C1519.57 184.161 1527.43 186.005 1534.5 189.694C1541.54 193.383 1547.19 198.96 1551.43 206.447C1555.66 213.935 1557.78 223.333 1557.78 234.706V327.257H1522.23V237.253C1522.23 229.503 1520.25 223.684 1516.32 219.797C1512.37 215.911 1506.81 213.979 1499.67 213.979C1491.58 213.979 1485.33 216.592 1480.91 221.796C1476.48 226.999 1474.27 234.421 1474.27 244.082V327.279H1438.73V237.275C1438.73 229.525 1436.74 223.706 1432.81 219.819C1428.87 215.933 1423.3 214.001 1416.17 214.001C1408.07 214.001 1401.83 216.614 1397.4 221.817C1392.97 227.021 1390.77 234.443 1390.77 244.104V327.301H1355.22L1355.18 327.213Z" fill="#1C2026"/>
|
|
||||||
<path d="M1645.78 331.185C1631.86 331.185 1619.59 328.199 1608.97 322.248C1598.34 316.276 1590.07 307.866 1584.14 296.975C1578.22 286.084 1575.26 273.261 1575.26 258.506V255.103C1575.26 240.347 1578.16 227.524 1584.01 216.633C1589.83 205.743 1598.01 197.333 1608.55 191.36C1619.09 185.41 1631.31 182.424 1645.23 182.424C1659.15 182.424 1670.91 185.498 1681.06 191.646C1691.21 197.794 1699.13 206.313 1704.76 217.204C1710.41 228.095 1713.22 240.721 1713.22 255.103V267.311H1611.37C1611.74 276.972 1615.32 284.811 1622.08 290.871C1628.85 296.931 1637.14 299.961 1646.91 299.961C1656.69 299.961 1664.22 297.788 1668.93 293.44C1673.62 289.093 1677.2 284.262 1679.64 278.97L1708.71 294.296C1706.07 299.215 1702.27 304.573 1697.27 310.347C1692.28 316.122 1685.66 321.04 1677.37 325.103C1669.1 329.165 1658.56 331.207 1645.78 331.207V331.185ZM1611.65 240.611H1677.11C1676.35 232.465 1673.12 225.943 1667.38 221.025C1661.64 216.106 1654.16 213.647 1644.95 213.647C1635.74 213.647 1627.73 216.106 1622.1 221.025C1616.45 225.943 1612.98 232.487 1611.67 240.611H1611.65Z" fill="#1C2026"/>
|
|
||||||
<path d="M1730.7 327.213V186.4H1765.68V201.726H1770.76C1773.2 197.006 1777.24 192.878 1782.89 189.386C1788.54 185.895 1795.96 184.139 1805.17 184.139C1815.14 184.139 1823.13 186.071 1829.15 189.957C1835.17 193.844 1839.78 198.894 1842.98 205.152H1848.07C1851.25 199.092 1855.77 194.085 1861.62 190.111C1867.44 186.137 1875.71 184.161 1886.45 184.161C1895.09 184.161 1902.94 186.005 1910.01 189.694C1917.06 193.383 1922.71 198.96 1926.95 206.447C1931.18 213.935 1933.3 223.333 1933.3 234.706V327.257H1897.75V237.253C1897.75 229.503 1895.77 223.684 1891.84 219.797C1887.89 215.911 1882.33 213.979 1875.19 213.979C1867.1 213.979 1860.85 216.592 1856.43 221.796C1852 226.999 1849.79 234.421 1849.79 244.082V327.279H1814.25V237.275C1814.25 229.525 1812.26 223.706 1808.33 219.819C1804.38 215.933 1798.82 214.001 1791.69 214.001C1783.59 214.001 1777.35 216.614 1772.92 221.817C1768.49 227.021 1766.29 234.443 1766.29 244.104V327.301H1730.74L1730.7 327.213Z" fill="#1C2026"/>
|
|
||||||
<path d="M2024.13 331.185C2010.21 331.185 1997.71 328.352 1986.6 322.665C1975.5 316.978 1966.75 308.744 1960.35 297.963C1953.96 287.182 1950.75 274.206 1950.75 259.077V254.532C1950.75 239.381 1953.94 226.426 1960.35 215.645C1966.75 204.864 1975.5 196.63 1986.6 190.943C1997.69 185.256 2010.21 182.424 2024.13 182.424C2038.06 182.424 2050.56 185.256 2061.66 190.943C2072.75 196.63 2081.5 204.864 2087.91 215.645C2094.31 226.426 2097.49 239.403 2097.49 254.532V259.077C2097.49 274.227 2094.28 287.182 2087.91 297.963C2081.52 308.744 2072.77 316.978 2061.66 322.665C2050.56 328.352 2038.06 331.185 2024.13 331.185ZM2024.13 299.391C2035.04 299.391 2044.06 295.833 2051.21 288.741C2058.37 281.649 2061.93 271.461 2061.93 258.221V255.388C2061.93 242.148 2058.39 231.96 2051.34 224.867C2044.3 217.775 2035.22 214.218 2024.11 214.218C2013.01 214.218 2004.17 217.775 1997.03 224.867C1989.88 231.96 1986.32 242.148 1986.32 255.388V258.221C1986.32 271.461 1989.88 281.649 1997.03 288.741C2004.19 295.833 2013.2 299.391 2024.11 299.391H2024.13Z" fill="#1C2026"/>
|
|
||||||
<path d="M2116.1 327.211V186.399H2151.08V202.296H2156.16C2158.24 196.609 2161.66 192.459 2166.46 189.802C2171.26 187.146 2176.85 185.828 2183.24 185.828H2200.18V217.622H2182.68C2173.64 217.622 2166.22 220.038 2160.4 224.868C2154.57 229.699 2151.65 237.12 2151.65 247.155V327.211H2116.1Z" fill="#1C2026"/>
|
|
||||||
<path d="M2228.95 383.994V352.771H2305.13C2310.38 352.771 2313.02 349.939 2313.02 344.252V308.769H2307.94C2306.43 311.996 2304.08 315.202 2300.89 318.43C2297.69 321.658 2293.36 324.292 2287.91 326.378C2282.45 328.464 2275.49 329.496 2267.03 329.496C2256.12 329.496 2246.56 326.993 2238.4 321.965C2230.22 316.959 2223.87 309.998 2219.35 301.106C2214.84 292.213 2212.59 281.981 2212.59 270.453V186.4H2248.13V267.599C2248.13 278.204 2250.71 286.153 2255.9 291.444C2261.07 296.736 2268.45 299.393 2278.05 299.393C2288.96 299.393 2297.42 295.748 2303.45 288.458C2309.47 281.168 2312.48 271.002 2312.48 257.937V186.4H2348.02V352.2C2348.02 361.861 2345.21 369.569 2339.56 375.343C2333.91 381.118 2326.38 383.994 2317 383.994H2228.97H2228.95Z" fill="#1C2026"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "./.wxt/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"types": ["chrome"]
|
|
||||||
},
|
|
||||||
"exclude": ["**/*.test.ts"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
/**
|
|
||||||
* API service for supermemory browser extension
|
|
||||||
*/
|
|
||||||
import { API_ENDPOINTS } from "./constants"
|
|
||||||
import { bearerToken, defaultProject, userData } from "./storage"
|
|
||||||
import { buildSearchMemoriesBody } from "./search-request"
|
|
||||||
import {
|
|
||||||
AuthenticationError,
|
|
||||||
type MemoryPayload,
|
|
||||||
type Project,
|
|
||||||
type ProjectsResponse,
|
|
||||||
SupermemoryAPIError,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get bearer token from storage
|
|
||||||
*/
|
|
||||||
async function getBearerToken(): Promise<string> {
|
|
||||||
const token = await bearerToken.getValue()
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new AuthenticationError("Bearer token not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make authenticated API request
|
|
||||||
*/
|
|
||||||
async function makeAuthenticatedRequest<T>(
|
|
||||||
endpoint: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const token = await getBearerToken()
|
|
||||||
|
|
||||||
const response = await fetch(`${API_ENDPOINTS.SUPERMEMORY_API}${endpoint}`, {
|
|
||||||
...options,
|
|
||||||
credentials: "omit",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
throw new AuthenticationError("Invalid or expired token")
|
|
||||||
}
|
|
||||||
throw new SupermemoryAPIError(
|
|
||||||
`API request failed: ${response.statusText}`,
|
|
||||||
response.status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all projects from API
|
|
||||||
*/
|
|
||||||
export async function fetchProjects(): Promise<Project[]> {
|
|
||||||
try {
|
|
||||||
const response =
|
|
||||||
await makeAuthenticatedRequest<ProjectsResponse>("/v3/projects")
|
|
||||||
return response.projects
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch projects:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default project from storage
|
|
||||||
*/
|
|
||||||
export async function getDefaultProject(): Promise<Project | null> {
|
|
||||||
try {
|
|
||||||
const defaultProjectValue = await defaultProject.getValue()
|
|
||||||
return defaultProjectValue || null
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get default project:", error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set default project in storage
|
|
||||||
*/
|
|
||||||
export async function setDefaultProject(project: Project): Promise<void> {
|
|
||||||
try {
|
|
||||||
await defaultProject.setValue(project)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to set default project:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate if current bearer token is still valid
|
|
||||||
*/
|
|
||||||
export async function validateAuthToken(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await makeAuthenticatedRequest<ProjectsResponse>("/v3/projects")
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AuthenticationError) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
console.error("Failed to validate auth token:", error)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user data from storage
|
|
||||||
*/
|
|
||||||
export async function getUserData(): Promise<{
|
|
||||||
email?: string
|
|
||||||
name?: string
|
|
||||||
} | null> {
|
|
||||||
try {
|
|
||||||
return (await userData.getValue()) || null
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to get user data:", error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save memory to Supermemory API
|
|
||||||
*/
|
|
||||||
export async function saveMemory(payload: MemoryPayload): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const response = await makeAuthenticatedRequest<unknown>("/v3/documents", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save memory:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search memories using Supermemory API
|
|
||||||
*/
|
|
||||||
export async function searchMemories(
|
|
||||||
query: string,
|
|
||||||
containerTag?: string,
|
|
||||||
): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const response = await makeAuthenticatedRequest<unknown>("/v4/search", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(buildSearchMemoriesBody(query, containerTag)),
|
|
||||||
})
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to search memories:", error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save tweet to Supermemory API (specific for Twitter imports)
|
|
||||||
*/
|
|
||||||
export async function saveAllTweets(
|
|
||||||
documents: MemoryPayload[],
|
|
||||||
): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const response = await makeAuthenticatedRequest<unknown>(
|
|
||||||
"/v3/documents/batch",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
documents,
|
|
||||||
metadata: {
|
|
||||||
sm_source: "consumer",
|
|
||||||
sm_internal_group_id: "twitter_bookmarks",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof SupermemoryAPIError && error.statusCode === 409) {
|
|
||||||
// Skip if already exists (409 Conflict)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/**
|
|
||||||
* API Endpoints
|
|
||||||
*/
|
|
||||||
export const API_ENDPOINTS = {
|
|
||||||
SUPERMEMORY_API: import.meta.env.PROD
|
|
||||||
? "https://api.supermemory.ai"
|
|
||||||
: "http://localhost:8787",
|
|
||||||
SUPERMEMORY_WEB: import.meta.env.PROD
|
|
||||||
? "https://app.supermemory.ai"
|
|
||||||
: "http://localhost:3000",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export function getSupermemoryLoginUrl(): string {
|
|
||||||
const baseUrl = API_ENDPOINTS.SUPERMEMORY_WEB
|
|
||||||
const loginUrl = new URL("/login", baseUrl)
|
|
||||||
const redirectUrl = new URL("/", baseUrl)
|
|
||||||
|
|
||||||
redirectUrl.searchParams.set("extension-auth-success", "true")
|
|
||||||
loginUrl.searchParams.set("redirect", redirectUrl.toString())
|
|
||||||
|
|
||||||
return loginUrl.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DOM Element IDs
|
|
||||||
*/
|
|
||||||
export const ELEMENT_IDS = {
|
|
||||||
TWITTER_IMPORT_BUTTON: "sm-twitter-import-button",
|
|
||||||
TWITTER_ONBOARDING_TOAST: "sm-twitter-onboarding-toast",
|
|
||||||
TWITTER_IMPORT_PROGRESS_TOAST: "sm-twitter-import-progress-toast",
|
|
||||||
SUPERMEMORY_TOAST: "sm-toast",
|
|
||||||
SUPERMEMORY_SAVE_BUTTON: "sm-save-button",
|
|
||||||
SAVE_TWEET_ELEMENT: "sm-save-tweet-element",
|
|
||||||
CHATGPT_INPUT_BAR_ELEMENT: "sm-chatgpt-input-bar-element",
|
|
||||||
CLAUDE_INPUT_BAR_ELEMENT: "sm-claude-input-bar-element",
|
|
||||||
GEMINI_INPUT_BAR_ELEMENT: "sm-gemini-input-bar-element",
|
|
||||||
T3_INPUT_BAR_ELEMENT: "sm-t3-input-bar-element",
|
|
||||||
PROJECT_SELECTION_MODAL: "sm-project-selection-modal",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage Keys for local
|
|
||||||
*/
|
|
||||||
export const STORAGE_KEYS = {
|
|
||||||
TWITTER_BOOKMARKS_ONBOARDING_SEEN: "sm_twitter_bookmarks_onboarding_seen",
|
|
||||||
TWITTER_BOOKMARKS_IMPORT_INTENT_UNTIL:
|
|
||||||
"sm_twitter_bookmarks_import_intent_until",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI Configuration
|
|
||||||
*/
|
|
||||||
export const UI_CONFIG = {
|
|
||||||
BUTTON_SHOW_DELAY: 2000, // milliseconds
|
|
||||||
TOAST_DURATION: 3000, // milliseconds
|
|
||||||
ONBOARDING_TOAST_DURATION: 6000, // milliseconds (6 seconds for progress bar)
|
|
||||||
IMPORT_INTENT_TTL: 2 * 60 * 1000, // 2 minutes TTL for import intent
|
|
||||||
RATE_LIMIT_BASE_WAIT: 60000, // 1 minute
|
|
||||||
PAGINATION_DELAY: 1000, // 1 second between requests
|
|
||||||
AUTO_SEARCH_DEBOUNCE_DELAY: 1500, // milliseconds to wait after user stops typing
|
|
||||||
OBSERVER_THROTTLE_DELAY: 300, // milliseconds between observer callback executions
|
|
||||||
ROUTE_CHECK_INTERVAL: 2000, // milliseconds between route change checks
|
|
||||||
API_REQUEST_TIMEOUT: 10000, // milliseconds for API request timeout
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported Domains
|
|
||||||
*/
|
|
||||||
export const DOMAINS = {
|
|
||||||
TWITTER: ["x.com", "twitter.com"],
|
|
||||||
CHATGPT: ["chatgpt.com", "chat.openai.com"],
|
|
||||||
CLAUDE: ["claude.ai"],
|
|
||||||
GROK: ["grok.com", "x.ai"],
|
|
||||||
GEMINI: ["gemini.google.com"],
|
|
||||||
T3: ["t3.chat"],
|
|
||||||
SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"],
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Container Tags
|
|
||||||
*/
|
|
||||||
export const CONTAINER_TAGS = {
|
|
||||||
TWITTER_BOOKMARKS: "sm_project_twitter_bookmarks",
|
|
||||||
DEFAULT_PROJECT: "sm_project_default",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Message Types for extension communication
|
|
||||||
*/
|
|
||||||
export const MESSAGE_TYPES = {
|
|
||||||
SAVE_MEMORY: "sm-save-memory",
|
|
||||||
SHOW_TOAST: "sm-show-toast",
|
|
||||||
BATCH_IMPORT_ALL: "sm-batch-import-all",
|
|
||||||
IMPORT_UPDATE: "sm-import-update",
|
|
||||||
IMPORT_DONE: "sm-import-done",
|
|
||||||
GET_RELATED_MEMORIES: "sm-get-related-memories",
|
|
||||||
CAPTURE_PROMPT: "sm-capture-prompt",
|
|
||||||
FETCH_PROJECTS: "sm-fetch-projects",
|
|
||||||
TWITTER_IMPORT_OPEN_MODAL: "sm-twitter-import-open-modal",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const POSTHOG_EVENT_KEY = {
|
|
||||||
TWITTER_IMPORT_STARTED: "twitter_import_started",
|
|
||||||
SAVE_MEMORY_ATTEMPTED: "save_memory_attempted",
|
|
||||||
SAVE_MEMORY_ATTEMPT_FAILED: "save_memory_attempt_failed",
|
|
||||||
SOURCE: "extension",
|
|
||||||
T3_CHAT_MEMORIES_SEARCHED: "t3_chat_memories_searched",
|
|
||||||
T3_CHAT_MEMORIES_AUTO_SEARCHED: "t3_chat_memories_auto_searched",
|
|
||||||
CLAUDE_CHAT_MEMORIES_SEARCHED: "claude_chat_memories_searched",
|
|
||||||
CLAUDE_CHAT_MEMORIES_AUTO_SEARCHED: "claude_chat_memories_auto_searched",
|
|
||||||
GEMINI_CHAT_MEMORIES_SEARCHED: "gemini_chat_memories_searched",
|
|
||||||
GEMINI_CHAT_MEMORIES_AUTO_SEARCHED: "gemini_chat_memories_auto_searched",
|
|
||||||
CHATGPT_CHAT_MEMORIES_SEARCHED: "chatgpt_chat_memories_searched",
|
|
||||||
CHATGPT_CHAT_MEMORIES_AUTO_SEARCHED: "chatgpt_chat_memories_auto_searched",
|
|
||||||
} as const
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
/**
|
|
||||||
* Memory Popup Utilities
|
|
||||||
* Standardized popup positioning and styling for memory display across platforms
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface MemoryPopupConfig {
|
|
||||||
memoriesData: string
|
|
||||||
onClose: () => void
|
|
||||||
onRemove?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMemoryPopup(config: MemoryPopupConfig): HTMLElement {
|
|
||||||
const popup = document.createElement("div")
|
|
||||||
popup.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
bottom: 80px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: white;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
|
||||||
max-width: 500px;
|
|
||||||
max-height: 400px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 999999;
|
|
||||||
display: none;
|
|
||||||
overflow: hidden;
|
|
||||||
`
|
|
||||||
|
|
||||||
const header = document.createElement("div")
|
|
||||||
header.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
opacity: 0.8;
|
|
||||||
`
|
|
||||||
header.innerHTML = `
|
|
||||||
<span style="font-size: 11px; font-weight: 600; letter-spacing: 0.5px;">INCLUDED MEMORIES</span>
|
|
||||||
<div style="display: flex; gap: 4px;">
|
|
||||||
${config.onRemove ? '<button id="remove-memories-btn" style="background: none; border: none; color: #ff4444; cursor: pointer; font-size: 14px; padding: 2px; border-radius: 2px;" title="Remove memories">✕</button>' : ""}
|
|
||||||
<button id="close-popup-btn" style="background: none; border: none; color: white; cursor: pointer; font-size: 14px; padding: 2px; border-radius: 2px;">✕</button>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
const content = document.createElement("div")
|
|
||||||
content.style.cssText = `
|
|
||||||
padding: 8px;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
line-height: 1.4;
|
|
||||||
`
|
|
||||||
content.textContent = config.memoriesData
|
|
||||||
|
|
||||||
const closeBtn = header.querySelector("#close-popup-btn")
|
|
||||||
closeBtn?.addEventListener("click", config.onClose)
|
|
||||||
|
|
||||||
const removeBtn = header.querySelector("#remove-memories-btn")
|
|
||||||
if (removeBtn && config.onRemove) {
|
|
||||||
removeBtn.addEventListener("click", config.onRemove)
|
|
||||||
}
|
|
||||||
|
|
||||||
popup.appendChild(header)
|
|
||||||
popup.appendChild(content)
|
|
||||||
|
|
||||||
return popup
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showMemoryPopup(popup: HTMLElement): void {
|
|
||||||
popup.style.display = "block"
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (popup.style.display === "block") {
|
|
||||||
hideMemoryPopup(popup)
|
|
||||||
}
|
|
||||||
}, 10000)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hideMemoryPopup(popup: HTMLElement): void {
|
|
||||||
popup.style.display = "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toggleMemoryPopup(popup: HTMLElement): void {
|
|
||||||
if (popup.style.display === "none" || popup.style.display === "") {
|
|
||||||
showMemoryPopup(popup)
|
|
||||||
} else {
|
|
||||||
hideMemoryPopup(popup)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { PostHog } from "posthog-js/dist/module.no-external"
|
|
||||||
import { userData } from "./storage"
|
|
||||||
|
|
||||||
export async function identifyUser(posthog: PostHog): Promise<void> {
|
|
||||||
const storedUserData = await userData.getValue()
|
|
||||||
|
|
||||||
if (storedUserData?.userId) {
|
|
||||||
posthog.identify(storedUserData.userId, {
|
|
||||||
email: storedUserData.email,
|
|
||||||
name: storedUserData.name,
|
|
||||||
userId: storedUserData.userId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let posthogInstance: PostHog | null = null
|
|
||||||
let initializationPromise: Promise<PostHog> | null = null
|
|
||||||
|
|
||||||
export const POSTHOG_CONFIG = {
|
|
||||||
api_host: "https://api.supermemory.ai/orange",
|
|
||||||
person_profiles: "identified_only",
|
|
||||||
disable_external_dependency_loading: true,
|
|
||||||
persistence: "localStorage",
|
|
||||||
capture_pageview: false,
|
|
||||||
autocapture: false,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export async function getPostHogInstance(): Promise<PostHog> {
|
|
||||||
if (posthogInstance) {
|
|
||||||
return posthogInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initializationPromise) {
|
|
||||||
return initializationPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
initializationPromise = initializePostHog()
|
|
||||||
return initializationPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializePostHog(): Promise<PostHog> {
|
|
||||||
try {
|
|
||||||
const posthog = new PostHog()
|
|
||||||
|
|
||||||
if (!import.meta.env.WXT_POSTHOG_API_KEY) {
|
|
||||||
console.error("PostHog API key not configured")
|
|
||||||
throw new Error("PostHog API key not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
posthog.init(
|
|
||||||
"phc_ShqecfUPQgf16lWu6ZMUzduQvcWzCywrkCz5KHwmWsv",
|
|
||||||
POSTHOG_CONFIG,
|
|
||||||
)
|
|
||||||
|
|
||||||
await identifyUser(posthog)
|
|
||||||
|
|
||||||
posthogInstance = posthog
|
|
||||||
return posthog
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize PostHog:", error)
|
|
||||||
initializationPromise = null
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function trackEvent(
|
|
||||||
eventName: string,
|
|
||||||
properties?: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const posthog = await getPostHogInstance()
|
|
||||||
posthog.capture(eventName, properties)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to track event ${eventName}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/**
|
|
||||||
* React Query configuration for supermemory browser extension
|
|
||||||
*/
|
|
||||||
import { QueryClient } from "@tanstack/react-query"
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes (previously cacheTime)
|
|
||||||
retry: (failureCount, error) => {
|
|
||||||
// Don't retry on authentication errors
|
|
||||||
if (error?.constructor?.name === "AuthenticationError") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return failureCount < 3
|
|
||||||
},
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
/**
|
|
||||||
* React Query hooks for supermemory API
|
|
||||||
*/
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import {
|
|
||||||
fetchProjects,
|
|
||||||
getDefaultProject,
|
|
||||||
getUserData,
|
|
||||||
saveMemory,
|
|
||||||
searchMemories,
|
|
||||||
setDefaultProject,
|
|
||||||
} from "./api"
|
|
||||||
import type { MemoryPayload } from "./types"
|
|
||||||
|
|
||||||
// Query Keys
|
|
||||||
export const queryKeys = {
|
|
||||||
projects: ["projects"] as const,
|
|
||||||
defaultProject: ["defaultProject"] as const,
|
|
||||||
userData: ["userData"] as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Projects Query
|
|
||||||
export function useProjects(options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.projects,
|
|
||||||
queryFn: fetchProjects,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
enabled: options?.enabled ?? true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default Project Query
|
|
||||||
export function useDefaultProject(options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.defaultProject,
|
|
||||||
queryFn: getDefaultProject,
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
|
||||||
enabled: options?.enabled ?? true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// User Data Query
|
|
||||||
export function useUserData(options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.userData,
|
|
||||||
queryFn: getUserData,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
enabled: options?.enabled ?? true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Default Project Mutation
|
|
||||||
export function useSetDefaultProject() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: setDefaultProject,
|
|
||||||
onSuccess: (_, project) => {
|
|
||||||
queryClient.setQueryData(queryKeys.defaultProject, project)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save Memory Mutation
|
|
||||||
export function useSaveMemory() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: MemoryPayload) => saveMemory(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search Memories Mutation
|
|
||||||
export function useSearchMemories() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (query: string) => searchMemories(query),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
/**
|
|
||||||
* Route Detection Utilities
|
|
||||||
* Shared logic for detecting route changes across different AI chat platforms
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { UI_CONFIG } from "./constants"
|
|
||||||
|
|
||||||
export interface RouteDetectionConfig {
|
|
||||||
platform: string
|
|
||||||
selectors: string[]
|
|
||||||
reinitCallback: () => void
|
|
||||||
checkInterval?: number
|
|
||||||
observerThrottleDelay?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RouteDetectionCleanup {
|
|
||||||
observer: MutationObserver | null
|
|
||||||
urlCheckInterval: NodeJS.Timeout | null
|
|
||||||
observerThrottle: NodeJS.Timeout | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRouteDetection(
|
|
||||||
config: RouteDetectionConfig,
|
|
||||||
cleanup: RouteDetectionCleanup,
|
|
||||||
): void {
|
|
||||||
if (cleanup.observer) {
|
|
||||||
cleanup.observer.disconnect()
|
|
||||||
}
|
|
||||||
if (cleanup.urlCheckInterval) {
|
|
||||||
clearInterval(cleanup.urlCheckInterval)
|
|
||||||
}
|
|
||||||
if (cleanup.observerThrottle) {
|
|
||||||
clearTimeout(cleanup.observerThrottle)
|
|
||||||
cleanup.observerThrottle = null
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentUrl = window.location.href
|
|
||||||
|
|
||||||
const checkForRouteChange = () => {
|
|
||||||
if (window.location.href !== currentUrl) {
|
|
||||||
currentUrl = window.location.href
|
|
||||||
setTimeout(config.reinitCallback, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup.urlCheckInterval = setInterval(
|
|
||||||
checkForRouteChange,
|
|
||||||
config.checkInterval || UI_CONFIG.ROUTE_CHECK_INTERVAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
cleanup.observer = new MutationObserver((mutations) => {
|
|
||||||
if (cleanup.observerThrottle) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldRecheck = false
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
|
|
||||||
mutation.addedNodes.forEach((node) => {
|
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
const element = node as Element
|
|
||||||
|
|
||||||
for (const selector of config.selectors) {
|
|
||||||
if (
|
|
||||||
element.querySelector?.(selector) ||
|
|
||||||
element.matches?.(selector)
|
|
||||||
) {
|
|
||||||
shouldRecheck = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (shouldRecheck) {
|
|
||||||
cleanup.observerThrottle = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
cleanup.observerThrottle = null
|
|
||||||
config.reinitCallback()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error in ${config.platform} observer callback:`, error)
|
|
||||||
}
|
|
||||||
}, config.observerThrottleDelay || UI_CONFIG.OBSERVER_THROTTLE_DELAY)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
cleanup.observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to set up ${config.platform} route observer:`, error)
|
|
||||||
if (cleanup.urlCheckInterval) {
|
|
||||||
clearInterval(cleanup.urlCheckInterval)
|
|
||||||
}
|
|
||||||
cleanup.urlCheckInterval = setInterval(checkForRouteChange, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cleanupRouteDetection(cleanup: RouteDetectionCleanup): void {
|
|
||||||
if (cleanup.observer) {
|
|
||||||
cleanup.observer.disconnect()
|
|
||||||
cleanup.observer = null
|
|
||||||
}
|
|
||||||
if (cleanup.urlCheckInterval) {
|
|
||||||
clearInterval(cleanup.urlCheckInterval)
|
|
||||||
cleanup.urlCheckInterval = null
|
|
||||||
}
|
|
||||||
if (cleanup.observerThrottle) {
|
|
||||||
clearTimeout(cleanup.observerThrottle)
|
|
||||||
cleanup.observerThrottle = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { describe, expect, it } from "bun:test"
|
|
||||||
import { buildSearchMemoriesBody } from "./search-request"
|
|
||||||
|
|
||||||
describe("buildSearchMemoriesBody", () => {
|
|
||||||
it("builds the default related-memory search body", () => {
|
|
||||||
expect(buildSearchMemoriesBody("deploy notes")).toEqual({
|
|
||||||
q: "deploy notes",
|
|
||||||
include: { relatedMemories: true },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("includes the container tag when provided", () => {
|
|
||||||
expect(buildSearchMemoriesBody("deploy notes", "sm_project_docs")).toEqual({
|
|
||||||
q: "deploy notes",
|
|
||||||
include: { relatedMemories: true },
|
|
||||||
containerTag: "sm_project_docs",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
export function buildSearchMemoriesBody(
|
|
||||||
query: string,
|
|
||||||
containerTag?: string,
|
|
||||||
): {
|
|
||||||
q: string
|
|
||||||
include: { relatedMemories: boolean }
|
|
||||||
containerTag?: string
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
q: query,
|
|
||||||
include: { relatedMemories: true },
|
|
||||||
...(containerTag ? { containerTag } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
/**
|
|
||||||
* Centralized storage layer using WXT's built-in storage API
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { storage } from "#imports"
|
|
||||||
import type { Project } from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User authentication and profile data
|
|
||||||
*/
|
|
||||||
export interface UserData {
|
|
||||||
userId?: string
|
|
||||||
email?: string
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Twitter authentication tokens for API requests
|
|
||||||
*/
|
|
||||||
export interface TwitterAuthTokens {
|
|
||||||
cookie: string
|
|
||||||
csrf: string
|
|
||||||
auth: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local Storage Items (persistent across sessions)
|
|
||||||
*/
|
|
||||||
export const bearerToken = storage.defineItem<string>("local:bearer-token")
|
|
||||||
|
|
||||||
export const userData = storage.defineItem<UserData>("local:user-data")
|
|
||||||
|
|
||||||
export const defaultProject = storage.defineItem<Project>(
|
|
||||||
"local:sm-default-project",
|
|
||||||
)
|
|
||||||
|
|
||||||
export const autoSearchEnabled = storage.defineItem<boolean>(
|
|
||||||
"local:sm-auto-search-enabled",
|
|
||||||
{
|
|
||||||
fallback: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const autoCapturePromptsEnabled = storage.defineItem<boolean>(
|
|
||||||
"local:sm-auto-capture-prompts-enabled",
|
|
||||||
{
|
|
||||||
fallback: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Session Storage Items (cleared when browser closes)
|
|
||||||
*/
|
|
||||||
export const tokensLogged = storage.defineItem<boolean>(
|
|
||||||
"session:tokens-logged",
|
|
||||||
{
|
|
||||||
fallback: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const twitterCookie = storage.defineItem<string>(
|
|
||||||
"session:twitter-cookie",
|
|
||||||
)
|
|
||||||
|
|
||||||
export const twitterCsrf = storage.defineItem<string>("session:twitter-csrf")
|
|
||||||
|
|
||||||
export const twitterAuthToken = storage.defineItem<string>(
|
|
||||||
"session:twitter-auth-token",
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to get Twitter authentication tokens
|
|
||||||
* @returns Promise resolving to tokens or null if not available
|
|
||||||
*/
|
|
||||||
export async function getTwitterTokens(): Promise<TwitterAuthTokens | null> {
|
|
||||||
const [cookie, csrf, auth] = await Promise.all([
|
|
||||||
twitterCookie.getValue(),
|
|
||||||
twitterCsrf.getValue(),
|
|
||||||
twitterAuthToken.getValue(),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!cookie || !csrf || !auth) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cookie,
|
|
||||||
csrf,
|
|
||||||
auth,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to set Twitter authentication tokens
|
|
||||||
* @param tokens - Twitter authentication tokens to store
|
|
||||||
*/
|
|
||||||
export async function setTwitterTokens(
|
|
||||||
tokens: TwitterAuthTokens,
|
|
||||||
): Promise<void> {
|
|
||||||
await Promise.all([
|
|
||||||
twitterCookie.setValue(tokens.cookie),
|
|
||||||
twitterCsrf.setValue(tokens.csrf),
|
|
||||||
twitterAuthToken.setValue(tokens.auth),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to check if tokens have been logged (for one-time logging)
|
|
||||||
* @returns Promise resolving to boolean indicating if tokens were previously logged
|
|
||||||
*/
|
|
||||||
export async function getTokensLogged(): Promise<boolean> {
|
|
||||||
return (await tokensLogged.getValue()) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to mark tokens as logged
|
|
||||||
*/
|
|
||||||
export async function setTokensLogged(): Promise<void> {
|
|
||||||
await tokensLogged.setValue(true)
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
/**
|
|
||||||
* Twitter Authentication Module
|
|
||||||
* Handles token capture and storage for Twitter API access
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
getTokensLogged,
|
|
||||||
setTokensLogged,
|
|
||||||
setTwitterTokens,
|
|
||||||
type TwitterAuthTokens,
|
|
||||||
} from "./storage"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captures Twitter authentication tokens from web request headers
|
|
||||||
* @param details - Web request details containing headers
|
|
||||||
* @returns True if tokens were captured, false otherwise
|
|
||||||
*/
|
|
||||||
export async function captureTwitterTokens(
|
|
||||||
details: chrome.webRequest.WebRequestDetails & {
|
|
||||||
requestHeaders?: chrome.webRequest.HttpHeader[]
|
|
||||||
},
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!(details.url.includes("x.com") || details.url.includes("twitter.com"))) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let authHeader: chrome.webRequest.HttpHeader | undefined
|
|
||||||
let cookieHeader: chrome.webRequest.HttpHeader | undefined
|
|
||||||
let csrfHeader: chrome.webRequest.HttpHeader | undefined
|
|
||||||
|
|
||||||
if (details.requestHeaders) {
|
|
||||||
for (const header of details.requestHeaders) {
|
|
||||||
if (!header.name) continue
|
|
||||||
const name = header.name.toLowerCase()
|
|
||||||
|
|
||||||
switch (name) {
|
|
||||||
case "authorization":
|
|
||||||
authHeader = header
|
|
||||||
break
|
|
||||||
case "cookie":
|
|
||||||
cookieHeader = header
|
|
||||||
break
|
|
||||||
case "x-csrf-token":
|
|
||||||
csrfHeader = header
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authHeader && cookieHeader && csrfHeader) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authHeader?.value && cookieHeader?.value && csrfHeader?.value) {
|
|
||||||
const tokensAlreadyLogged = await getTokensLogged()
|
|
||||||
if (!tokensAlreadyLogged) {
|
|
||||||
await setTokensLogged()
|
|
||||||
}
|
|
||||||
|
|
||||||
await setTwitterTokens({
|
|
||||||
cookie: cookieHeader.value,
|
|
||||||
csrf: csrfHeader.value,
|
|
||||||
auth: authHeader.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates HTTP headers for Twitter API requests using stored tokens
|
|
||||||
* @param tokens - Twitter authentication tokens
|
|
||||||
* @returns Headers object ready for fetch requests
|
|
||||||
*/
|
|
||||||
export function createTwitterAPIHeaders(tokens: TwitterAuthTokens): Headers {
|
|
||||||
const headers = new Headers()
|
|
||||||
headers.append("Cookie", tokens.cookie)
|
|
||||||
headers.append("X-Csrf-Token", tokens.csrf)
|
|
||||||
headers.append("Authorization", tokens.auth)
|
|
||||||
headers.append("Content-Type", "application/json")
|
|
||||||
headers.append(
|
|
||||||
"User-Agent",
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
||||||
)
|
|
||||||
headers.append("Accept", "*/*")
|
|
||||||
headers.append("Accept-Language", "en-US,en;q=0.9")
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { describe, expect, it } from "bun:test"
|
|
||||||
import { expandTweetText } from "./twitter-utils"
|
|
||||||
|
|
||||||
const link = (url: string, expanded_url: string, display_url: string) => ({
|
|
||||||
url,
|
|
||||||
expanded_url,
|
|
||||||
display_url,
|
|
||||||
indices: [0, url.length] as [number, number],
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("expandTweetText", () => {
|
|
||||||
it("returns the text unchanged when there are no url entities", () => {
|
|
||||||
expect(expandTweetText("just text", undefined)).toBe("just text")
|
|
||||||
expect(expandTweetText("just text", [])).toBe("just text")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("replaces a t.co shortlink with a markdown link to the expanded url", () => {
|
|
||||||
const text = "check this https://t.co/abc123 out"
|
|
||||||
const urls = [
|
|
||||||
link(
|
|
||||||
"https://t.co/abc123",
|
|
||||||
"https://example.com/article",
|
|
||||||
"example.com/article",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
expect(expandTweetText(text, urls)).toBe(
|
|
||||||
"check this [example.com/article](https://example.com/article) out",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("expands multiple shortlinks including repeats", () => {
|
|
||||||
const text = "a https://t.co/aaa b https://t.co/bbb c https://t.co/aaa"
|
|
||||||
const urls = [
|
|
||||||
link("https://t.co/aaa", "https://a.com", "a.com"),
|
|
||||||
link("https://t.co/bbb", "https://b.com", "b.com"),
|
|
||||||
]
|
|
||||||
expect(expandTweetText(text, urls)).toBe(
|
|
||||||
"a [a.com](https://a.com) b [b.com](https://b.com) c [a.com](https://a.com)",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("falls back to the expanded url as label when display_url is empty", () => {
|
|
||||||
const text = "see https://t.co/xyz"
|
|
||||||
const urls = [link("https://t.co/xyz", "https://long.example.com/path", "")]
|
|
||||||
expect(expandTweetText(text, urls)).toBe(
|
|
||||||
"see [https://long.example.com/path](https://long.example.com/path)",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("skips entries missing a url or expanded_url", () => {
|
|
||||||
const text = "keep https://t.co/keep here"
|
|
||||||
const urls = [
|
|
||||||
link("", "https://nope.com", "nope.com"),
|
|
||||||
link("https://t.co/keep", "", "keep.com"),
|
|
||||||
]
|
|
||||||
expect(expandTweetText(text, urls)).toBe("keep https://t.co/keep here")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
/**
|
|
||||||
* Twitter Bookmarks Import Module
|
|
||||||
* Handles the import process for Twitter bookmarks
|
|
||||||
*/
|
|
||||||
import { saveAllTweets } from "./api"
|
|
||||||
import type { MemoryPayload } from "./types"
|
|
||||||
import { createTwitterAPIHeaders } from "./twitter-auth"
|
|
||||||
import { getTwitterTokens } from "./storage"
|
|
||||||
import {
|
|
||||||
BOOKMARKS_URL,
|
|
||||||
BOOKMARK_COLLECTION_URL,
|
|
||||||
buildRequestVariables,
|
|
||||||
buildBookmarkCollectionVariables,
|
|
||||||
extractNextCursor,
|
|
||||||
getAllTweets,
|
|
||||||
type TwitterAPIResponse,
|
|
||||||
} from "./twitter-utils"
|
|
||||||
|
|
||||||
export type ImportProgressCallback = (message: string) => Promise<void>
|
|
||||||
|
|
||||||
export type ImportCompleteCallback = (totalImported: number) => Promise<void>
|
|
||||||
|
|
||||||
export interface TwitterImportConfig {
|
|
||||||
isFolderImport?: boolean
|
|
||||||
bookmarkCollectionId?: string
|
|
||||||
selectedProject?: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
containerTag: string
|
|
||||||
}
|
|
||||||
onProgress: ImportProgressCallback
|
|
||||||
onComplete: ImportCompleteCallback
|
|
||||||
onError: (error: Error) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting configuration
|
|
||||||
*/
|
|
||||||
class RateLimiter {
|
|
||||||
private waitTime = 60000 // Start with 1 minute
|
|
||||||
|
|
||||||
async handleRateLimit(onProgress: ImportProgressCallback): Promise<void> {
|
|
||||||
const waitTimeInSeconds = this.waitTime / 1000
|
|
||||||
|
|
||||||
await onProgress(
|
|
||||||
`Rate limit reached. Waiting for ${waitTimeInSeconds} seconds before retrying...`,
|
|
||||||
)
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, this.waitTime))
|
|
||||||
this.waitTime *= 2 // Exponential backoff
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.waitTime = 60000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main class for handling Twitter bookmarks import
|
|
||||||
*/
|
|
||||||
export class TwitterImporter {
|
|
||||||
private importInProgress = false
|
|
||||||
private rateLimiter = new RateLimiter()
|
|
||||||
|
|
||||||
constructor(private config: TwitterImportConfig) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the import process for all Twitter bookmarks
|
|
||||||
* @returns Promise that resolves when import is complete
|
|
||||||
*/
|
|
||||||
async startImport(): Promise<void> {
|
|
||||||
if (this.importInProgress) {
|
|
||||||
throw new Error("Import already in progress")
|
|
||||||
}
|
|
||||||
|
|
||||||
this.importInProgress = true
|
|
||||||
const uniqueGroupId = crypto.randomUUID()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.batchImportAll("", 0, uniqueGroupId)
|
|
||||||
this.rateLimiter.reset()
|
|
||||||
} catch (error) {
|
|
||||||
await this.config.onError(error as Error)
|
|
||||||
} finally {
|
|
||||||
this.importInProgress = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursive function to import all bookmarks with pagination
|
|
||||||
* @param cursor - Pagination cursor for Twitter API
|
|
||||||
* @param totalImported - Number of tweets imported so far
|
|
||||||
*/
|
|
||||||
private async batchImportAll(
|
|
||||||
cursor = "",
|
|
||||||
totalImported = 0,
|
|
||||||
uniqueGroupId = "twitter_bookmarks",
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Use a local variable to track imported count
|
|
||||||
let importedCount = totalImported
|
|
||||||
|
|
||||||
// Get authentication tokens
|
|
||||||
const tokens = await getTwitterTokens()
|
|
||||||
if (!tokens) {
|
|
||||||
await this.config.onProgress(
|
|
||||||
"Please visit Twitter/X first to capture authentication tokens",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create headers for API request
|
|
||||||
const headers = createTwitterAPIHeaders(tokens)
|
|
||||||
|
|
||||||
// Build API request with pagination
|
|
||||||
const collectionId = this.config.isFolderImport
|
|
||||||
? this.config.bookmarkCollectionId
|
|
||||||
: undefined
|
|
||||||
const variables = collectionId
|
|
||||||
? buildBookmarkCollectionVariables(collectionId, cursor)
|
|
||||||
: buildRequestVariables(cursor)
|
|
||||||
const baseUrl = collectionId ? BOOKMARK_COLLECTION_URL : BOOKMARKS_URL
|
|
||||||
const urlWithCursor = `${baseUrl}&variables=${encodeURIComponent(JSON.stringify(variables))}`
|
|
||||||
|
|
||||||
const response = await fetch(urlWithCursor, {
|
|
||||||
method: "GET",
|
|
||||||
headers,
|
|
||||||
redirect: "follow",
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
console.error(`Twitter API Error ${response.status}:`, errorText)
|
|
||||||
|
|
||||||
if (response.status === 429) {
|
|
||||||
await this.rateLimiter.handleRateLimit(this.config.onProgress)
|
|
||||||
return this.batchImportAll(cursor, totalImported, uniqueGroupId)
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch data: ${response.status} - ${errorText}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: TwitterAPIResponse = await response.json()
|
|
||||||
const tweets = getAllTweets(data)
|
|
||||||
|
|
||||||
const documents: MemoryPayload[] = []
|
|
||||||
|
|
||||||
// Convert tweets to MemoryPayload
|
|
||||||
for (const tweet of tweets) {
|
|
||||||
try {
|
|
||||||
const metadata = {
|
|
||||||
sm_source: "consumer",
|
|
||||||
tweet_id: tweet.id_str,
|
|
||||||
author: tweet.user.screen_name,
|
|
||||||
created_at: tweet.created_at,
|
|
||||||
likes: tweet.favorite_count,
|
|
||||||
retweets: tweet.retweet_count || 0,
|
|
||||||
sm_internal_group_id: uniqueGroupId,
|
|
||||||
}
|
|
||||||
const containerTag =
|
|
||||||
this.config.selectedProject?.containerTag ||
|
|
||||||
"sm_project_twitter_bookmarks"
|
|
||||||
|
|
||||||
documents.push({
|
|
||||||
containerTags: [containerTag],
|
|
||||||
content: `https://x.com/${tweet.user.screen_name}/status/${tweet.id_str}`,
|
|
||||||
metadata,
|
|
||||||
customId: tweet.id_str,
|
|
||||||
})
|
|
||||||
importedCount++
|
|
||||||
await this.config.onProgress(
|
|
||||||
`Imported ${importedCount} tweets, so far...`,
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error importing tweet:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (documents.length > 0) {
|
|
||||||
await saveAllTweets(documents)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving tweets batch:", error)
|
|
||||||
await this.config.onError(error as Error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle pagination
|
|
||||||
const instructions =
|
|
||||||
data.data?.bookmark_timeline_v2?.timeline?.instructions ||
|
|
||||||
data.data?.bookmark_collection_timeline?.timeline?.instructions ||
|
|
||||||
[]
|
|
||||||
const nextCursor = extractNextCursor(instructions)
|
|
||||||
|
|
||||||
if (nextCursor && tweets.length > 0) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000)) // Rate limiting
|
|
||||||
await this.batchImportAll(nextCursor, importedCount, uniqueGroupId)
|
|
||||||
} else {
|
|
||||||
await this.config.onComplete(importedCount)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Batch import error:", error)
|
|
||||||
await this.config.onError(error as Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,504 +0,0 @@
|
||||||
// Twitter API data structures and transformation utilities
|
|
||||||
|
|
||||||
interface TwitterAPITweet {
|
|
||||||
__typename?: string
|
|
||||||
legacy: {
|
|
||||||
lang?: string
|
|
||||||
favorite_count: number
|
|
||||||
created_at: string
|
|
||||||
display_text_range?: [number, number]
|
|
||||||
entities?: {
|
|
||||||
hashtags?: Array<{ indices: [number, number]; text: string }>
|
|
||||||
urls?: Array<{
|
|
||||||
display_url: string
|
|
||||||
expanded_url: string
|
|
||||||
indices: [number, number]
|
|
||||||
url: string
|
|
||||||
}>
|
|
||||||
user_mentions?: Array<{
|
|
||||||
id_str: string
|
|
||||||
indices: [number, number]
|
|
||||||
name: string
|
|
||||||
screen_name: string
|
|
||||||
}>
|
|
||||||
symbols?: Array<{ indices: [number, number]; text: string }>
|
|
||||||
media?: MediaEntity[]
|
|
||||||
}
|
|
||||||
id_str: string
|
|
||||||
full_text: string
|
|
||||||
reply_count?: number
|
|
||||||
retweet_count?: number
|
|
||||||
quote_count?: number
|
|
||||||
}
|
|
||||||
core?: {
|
|
||||||
user_results?: {
|
|
||||||
result?: {
|
|
||||||
legacy?: {
|
|
||||||
id_str: string
|
|
||||||
name: string
|
|
||||||
profile_image_url_https: string
|
|
||||||
screen_name: string
|
|
||||||
verified: boolean
|
|
||||||
}
|
|
||||||
is_blue_verified?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaEntity {
|
|
||||||
type: string
|
|
||||||
media_url_https: string
|
|
||||||
sizes?: {
|
|
||||||
large?: {
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
video_info?: {
|
|
||||||
variants?: VideoVariant[]
|
|
||||||
duration_millis?: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoVariant {
|
|
||||||
url: string
|
|
||||||
bitrate?: number
|
|
||||||
content_type?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Twitter returns several video variants for a single video: an HLS `.m3u8`
|
|
||||||
* playlist (no bitrate) plus multiple `video/mp4` renditions at different
|
|
||||||
* bitrates, in no guaranteed order. Taking `variants[0]` therefore often stored
|
|
||||||
* the HLS playlist URL (not a directly usable file) or the lowest-quality clip.
|
|
||||||
* Pick the highest-bitrate MP4 instead, falling back to the first variant when
|
|
||||||
* no MP4 rendition is present.
|
|
||||||
*/
|
|
||||||
export function pickBestVideoVariantUrl(
|
|
||||||
variants: VideoVariant[] | undefined,
|
|
||||||
): string {
|
|
||||||
if (!variants || variants.length === 0) return ""
|
|
||||||
|
|
||||||
const mp4s = variants.filter(
|
|
||||||
(v) => v.content_type === "video/mp4" || /\.mp4(?:\?|$)/i.test(v.url),
|
|
||||||
)
|
|
||||||
const pool = mp4s.length > 0 ? mp4s : variants
|
|
||||||
|
|
||||||
let best = pool[0]
|
|
||||||
for (const variant of pool) {
|
|
||||||
if ((variant.bitrate ?? 0) > (best?.bitrate ?? 0)) {
|
|
||||||
best = variant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best?.url || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tweet {
|
|
||||||
__typename?: string
|
|
||||||
lang?: string
|
|
||||||
favorite_count: number
|
|
||||||
created_at: string
|
|
||||||
display_text_range?: [number, number]
|
|
||||||
entities: {
|
|
||||||
hashtags: Array<{
|
|
||||||
indices: [number, number]
|
|
||||||
text: string
|
|
||||||
}>
|
|
||||||
urls?: Array<{
|
|
||||||
display_url: string
|
|
||||||
expanded_url: string
|
|
||||||
indices: [number, number]
|
|
||||||
url: string
|
|
||||||
}>
|
|
||||||
user_mentions: Array<{
|
|
||||||
id_str: string
|
|
||||||
indices: [number, number]
|
|
||||||
name: string
|
|
||||||
screen_name: string
|
|
||||||
}>
|
|
||||||
symbols: Array<{
|
|
||||||
indices: [number, number]
|
|
||||||
text: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
id_str: string
|
|
||||||
text: string
|
|
||||||
user: {
|
|
||||||
id_str: string
|
|
||||||
name: string
|
|
||||||
profile_image_url_https: string
|
|
||||||
screen_name: string
|
|
||||||
verified: boolean
|
|
||||||
is_blue_verified?: boolean
|
|
||||||
}
|
|
||||||
conversation_count: number
|
|
||||||
photos?: Array<{
|
|
||||||
url: string
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}>
|
|
||||||
videos?: Array<{
|
|
||||||
url: string
|
|
||||||
thumbnail_url: string
|
|
||||||
duration: number
|
|
||||||
}>
|
|
||||||
retweet_count?: number
|
|
||||||
quote_count?: number
|
|
||||||
reply_count?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TwitterAPIResponse {
|
|
||||||
data: {
|
|
||||||
bookmark_timeline_v2?: {
|
|
||||||
timeline: {
|
|
||||||
instructions: Array<{
|
|
||||||
type: string
|
|
||||||
entries?: Array<{
|
|
||||||
entryId: string
|
|
||||||
sortIndex: string
|
|
||||||
content: Record<string, unknown>
|
|
||||||
}>
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bookmark_collection_timeline?: {
|
|
||||||
timeline: {
|
|
||||||
instructions: Array<{
|
|
||||||
type: string
|
|
||||||
entries?: Array<{
|
|
||||||
entryId: string
|
|
||||||
sortIndex: string
|
|
||||||
content: Record<string, unknown>
|
|
||||||
}>
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Twitter API features configuration
|
|
||||||
export const TWITTER_API_FEATURES = {
|
|
||||||
graphql_timeline_v2_bookmark_timeline: true,
|
|
||||||
responsive_web_graphql_exclude_directive_enabled: true,
|
|
||||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
||||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
||||||
responsive_web_enhance_cards_enabled: false,
|
|
||||||
rweb_tipjar_consumption_enabled: true,
|
|
||||||
responsive_web_twitter_article_notes_tab_enabled: true,
|
|
||||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
||||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
||||||
standardized_nudges_misinfo: true,
|
|
||||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
||||||
longform_notetweets_rich_text_read_enabled: true,
|
|
||||||
longform_notetweets_inline_media_enabled: true,
|
|
||||||
responsive_web_media_download_video_enabled: false,
|
|
||||||
responsive_web_text_conversations_enabled: false,
|
|
||||||
// Missing features that the API is complaining about
|
|
||||||
creator_subscriptions_quote_tweet_preview_enabled: true,
|
|
||||||
view_counts_everywhere_api_enabled: true,
|
|
||||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
||||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
||||||
tweetypie_unmention_optimization_enabled: true,
|
|
||||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
||||||
tweet_awards_web_tipping_enabled: true,
|
|
||||||
communities_web_enable_tweet_community_results_fetch: true,
|
|
||||||
responsive_web_edit_tweet_api_enabled: true,
|
|
||||||
longform_notetweets_consumption_enabled: true,
|
|
||||||
articles_preview_enabled: true,
|
|
||||||
rweb_video_timestamps_enabled: true,
|
|
||||||
verified_phone_label_enabled: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Twitter API features configuration for BookmarkFolderTimeline
|
|
||||||
export const TWITTER_BOOKMARK_FOLDER_FEATURES = {
|
|
||||||
rweb_video_screen_enabled: false,
|
|
||||||
payments_enabled: false,
|
|
||||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
||||||
responsive_web_profile_redirect_enabled: false,
|
|
||||||
rweb_tipjar_consumption_enabled: true,
|
|
||||||
verified_phone_label_enabled: false,
|
|
||||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
||||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
||||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
||||||
premium_content_api_read_enabled: false,
|
|
||||||
communities_web_enable_tweet_community_results_fetch: true,
|
|
||||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
||||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
||||||
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
||||||
responsive_web_jetfuel_frame: true,
|
|
||||||
responsive_web_grok_share_attachment_enabled: true,
|
|
||||||
articles_preview_enabled: true,
|
|
||||||
responsive_web_edit_tweet_api_enabled: true,
|
|
||||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
||||||
view_counts_everywhere_api_enabled: true,
|
|
||||||
longform_notetweets_consumption_enabled: true,
|
|
||||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
||||||
tweet_awards_web_tipping_enabled: false,
|
|
||||||
responsive_web_grok_show_grok_translated_post: true,
|
|
||||||
responsive_web_grok_analysis_button_from_backend: true,
|
|
||||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
|
||||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
||||||
standardized_nudges_misinfo: true,
|
|
||||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
||||||
longform_notetweets_rich_text_read_enabled: true,
|
|
||||||
longform_notetweets_inline_media_enabled: true,
|
|
||||||
responsive_web_grok_image_annotation_enabled: true,
|
|
||||||
responsive_web_grok_imagine_annotation_enabled: true,
|
|
||||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
|
||||||
responsive_web_enhance_cards_enabled: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BOOKMARKS_URL = `https://x.com/i/api/graphql/xLjCVTqYWz8CGSprLU349w/Bookmarks?features=${encodeURIComponent(JSON.stringify(TWITTER_API_FEATURES))}`
|
|
||||||
|
|
||||||
export const BOOKMARK_COLLECTION_URL = `https://x.com/i/api/graphql/I8Y9ni1dqP-ZSpwxqJQ--Q/BookmarkFolderTimeline?features=${encodeURIComponent(JSON.stringify(TWITTER_BOOKMARK_FOLDER_FEATURES))}`
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transform raw Twitter API response data into standardized Tweet format
|
|
||||||
*/
|
|
||||||
export function transformTweetData(
|
|
||||||
input: Record<string, unknown>,
|
|
||||||
): Tweet | null {
|
|
||||||
try {
|
|
||||||
const content = input.content as {
|
|
||||||
itemContent?: { tweet_results?: { result?: unknown } }
|
|
||||||
}
|
|
||||||
const tweetData = content?.itemContent?.tweet_results?.result
|
|
||||||
|
|
||||||
if (!tweetData) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const tweet = tweetData as TwitterAPITweet
|
|
||||||
|
|
||||||
if (!tweet.legacy) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle media entities
|
|
||||||
const media = (tweet.legacy.entities?.media as MediaEntity[]) || []
|
|
||||||
const photos = media
|
|
||||||
.filter((m) => m.type === "photo")
|
|
||||||
.map((m) => ({
|
|
||||||
url: m.media_url_https,
|
|
||||||
width: m.sizes?.large?.w || 0,
|
|
||||||
height: m.sizes?.large?.h || 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const videos = media
|
|
||||||
.filter((m) => m.type === "video")
|
|
||||||
.map((m) => ({
|
|
||||||
url: pickBestVideoVariantUrl(m.video_info?.variants),
|
|
||||||
thumbnail_url: m.media_url_https,
|
|
||||||
duration: m.video_info?.duration_millis || 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const transformed: Tweet = {
|
|
||||||
__typename: tweet.__typename,
|
|
||||||
lang: tweet.legacy?.lang,
|
|
||||||
favorite_count: tweet.legacy.favorite_count || 0,
|
|
||||||
created_at: new Date(tweet.legacy.created_at).toISOString(),
|
|
||||||
display_text_range: tweet.legacy.display_text_range,
|
|
||||||
entities: {
|
|
||||||
hashtags: tweet.legacy.entities?.hashtags || [],
|
|
||||||
urls: tweet.legacy.entities?.urls || [],
|
|
||||||
user_mentions: tweet.legacy.entities?.user_mentions || [],
|
|
||||||
symbols: tweet.legacy.entities?.symbols || [],
|
|
||||||
},
|
|
||||||
id_str: tweet.legacy.id_str,
|
|
||||||
text: tweet.legacy.full_text,
|
|
||||||
user: {
|
|
||||||
id_str: tweet.core?.user_results?.result?.legacy?.id_str || "",
|
|
||||||
name: tweet.core?.user_results?.result?.legacy?.name || "Unknown",
|
|
||||||
profile_image_url_https:
|
|
||||||
tweet.core?.user_results?.result?.legacy?.profile_image_url_https ||
|
|
||||||
"",
|
|
||||||
screen_name:
|
|
||||||
tweet.core?.user_results?.result?.legacy?.screen_name || "unknown",
|
|
||||||
verified: tweet.core?.user_results?.result?.legacy?.verified || false,
|
|
||||||
is_blue_verified:
|
|
||||||
tweet.core?.user_results?.result?.is_blue_verified || false,
|
|
||||||
},
|
|
||||||
conversation_count: tweet.legacy.reply_count || 0,
|
|
||||||
retweet_count: tweet.legacy.retweet_count || 0,
|
|
||||||
quote_count: tweet.legacy.quote_count || 0,
|
|
||||||
reply_count: tweet.legacy.reply_count || 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (photos.length > 0) {
|
|
||||||
transformed.photos = photos
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videos.length > 0) {
|
|
||||||
transformed.videos = videos
|
|
||||||
}
|
|
||||||
|
|
||||||
return transformed
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error transforming tweet data:", error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract all tweets from Twitter API response
|
|
||||||
*/
|
|
||||||
export function getAllTweets(data: TwitterAPIResponse): Tweet[] {
|
|
||||||
const tweets: Tweet[] = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const instructions =
|
|
||||||
data.data?.bookmark_timeline_v2?.timeline?.instructions ||
|
|
||||||
data.data?.bookmark_collection_timeline?.timeline?.instructions ||
|
|
||||||
[]
|
|
||||||
|
|
||||||
for (const instruction of instructions) {
|
|
||||||
if (instruction.type === "TimelineAddEntries" && instruction.entries) {
|
|
||||||
for (const entry of instruction.entries) {
|
|
||||||
if (entry.entryId.startsWith("tweet-")) {
|
|
||||||
const tweet = transformTweetData(entry)
|
|
||||||
if (tweet) {
|
|
||||||
tweets.push(tweet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error extracting tweets:", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tweets
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract pagination cursor from Twitter API response
|
|
||||||
*/
|
|
||||||
export function extractNextCursor(
|
|
||||||
instructions: Array<Record<string, unknown>>,
|
|
||||||
): string | null {
|
|
||||||
try {
|
|
||||||
for (const instruction of instructions) {
|
|
||||||
if (instruction.type === "TimelineAddEntries" && instruction.entries) {
|
|
||||||
const entries = instruction.entries as Array<{
|
|
||||||
entryId: string
|
|
||||||
content?: { value?: string }
|
|
||||||
}>
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.entryId.startsWith("cursor-bottom-")) {
|
|
||||||
return entry.content?.value || null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error extracting cursor:", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tweet `full_text` embeds links as opaque `t.co` shortlinks, while
|
|
||||||
* `entities.urls` carries the real destination. Replace each shortlink with a
|
|
||||||
* markdown link to its expanded URL (labelled with the human-readable
|
|
||||||
* display_url) so imported tweets keep working, searchable links instead of
|
|
||||||
* `https://t.co/xxxx`.
|
|
||||||
*/
|
|
||||||
export function expandTweetText(
|
|
||||||
text: string,
|
|
||||||
urls: Tweet["entities"]["urls"],
|
|
||||||
): string {
|
|
||||||
if (!urls || urls.length === 0) return text
|
|
||||||
let expanded = text
|
|
||||||
for (const link of urls) {
|
|
||||||
if (!link?.url || !link.expanded_url) continue
|
|
||||||
const label = link.display_url || link.expanded_url
|
|
||||||
expanded = expanded.split(link.url).join(`[${label}](${link.expanded_url})`)
|
|
||||||
}
|
|
||||||
return expanded
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Tweet object to markdown format for storage
|
|
||||||
*/
|
|
||||||
export function tweetToMarkdown(tweet: Tweet): string {
|
|
||||||
const username = tweet.user?.screen_name || "unknown"
|
|
||||||
const displayName = tweet.user?.name || "Unknown User"
|
|
||||||
const date = new Date(tweet.created_at).toLocaleDateString()
|
|
||||||
const time = new Date(tweet.created_at).toLocaleTimeString()
|
|
||||||
|
|
||||||
let markdown = `# Tweet by @${username} (${displayName})\n\n`
|
|
||||||
markdown += `**Date:** ${date} ${time}\n`
|
|
||||||
markdown += `**Likes:** ${tweet.favorite_count} | **Retweets:** ${tweet.retweet_count || 0} | **Replies:** ${tweet.reply_count || 0}\n\n`
|
|
||||||
|
|
||||||
// Add tweet text with t.co shortlinks expanded to their real destinations
|
|
||||||
markdown += `${expandTweetText(tweet.text, tweet.entities.urls)}\n\n`
|
|
||||||
|
|
||||||
// Add media if present
|
|
||||||
if (tweet.photos && tweet.photos.length > 0) {
|
|
||||||
markdown += "**Images:**\n"
|
|
||||||
tweet.photos.forEach((photo, index) => {
|
|
||||||
markdown += `\n`
|
|
||||||
})
|
|
||||||
markdown += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tweet.videos && tweet.videos.length > 0) {
|
|
||||||
markdown += "**Videos:**\n"
|
|
||||||
tweet.videos.forEach((video, index) => {
|
|
||||||
markdown += `[Video ${index + 1}](${video.url})\n`
|
|
||||||
})
|
|
||||||
markdown += "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add hashtags and mentions
|
|
||||||
if (tweet.entities.hashtags.length > 0) {
|
|
||||||
markdown += `**Hashtags:** ${tweet.entities.hashtags.map((h) => `#${h.text}`).join(", ")}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tweet.entities.user_mentions.length > 0) {
|
|
||||||
markdown += `**Mentions:** ${tweet.entities.user_mentions.map((m) => `@${m.screen_name}`).join(", ")}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add raw data for reference
|
|
||||||
markdown += `\n---\n<details>\n<summary>Raw Tweet Data</summary>\n\n\`\`\`json\n${JSON.stringify(tweet, null, 2)}\n\`\`\`\n</details>`
|
|
||||||
|
|
||||||
return markdown
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build Twitter API request variables for pagination
|
|
||||||
*/
|
|
||||||
export function buildRequestVariables(cursor?: string, count = 100) {
|
|
||||||
const variables = {
|
|
||||||
count,
|
|
||||||
includePromotedContent: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursor) {
|
|
||||||
;(variables as Record<string, unknown>).cursor = cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
return variables
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build Twitter API request variables for bookmark collection
|
|
||||||
*/
|
|
||||||
export function buildBookmarkCollectionVariables(
|
|
||||||
bookmarkCollectionId: string,
|
|
||||||
cursor?: string,
|
|
||||||
) {
|
|
||||||
const variables: Record<string, unknown> = {
|
|
||||||
bookmark_collection_id: bookmarkCollectionId,
|
|
||||||
includePromotedContent: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cursor) {
|
|
||||||
variables.cursor = cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
return variables
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { describe, expect, it } from "bun:test"
|
|
||||||
import { pickBestVideoVariantUrl } from "./twitter-utils"
|
|
||||||
|
|
||||||
describe("pickBestVideoVariantUrl", () => {
|
|
||||||
it("returns an empty string when there are no variants", () => {
|
|
||||||
expect(pickBestVideoVariantUrl(undefined)).toBe("")
|
|
||||||
expect(pickBestVideoVariantUrl([])).toBe("")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("picks the highest-bitrate mp4, not the first variant", () => {
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
url: "https://video.twimg.com/playlist.m3u8",
|
|
||||||
content_type: "application/x-mpegURL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://video.twimg.com/low.mp4",
|
|
||||||
content_type: "video/mp4",
|
|
||||||
bitrate: 256000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://video.twimg.com/high.mp4",
|
|
||||||
content_type: "video/mp4",
|
|
||||||
bitrate: 2176000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://video.twimg.com/mid.mp4",
|
|
||||||
content_type: "video/mp4",
|
|
||||||
bitrate: 832000,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(pickBestVideoVariantUrl(variants)).toBe(
|
|
||||||
"https://video.twimg.com/high.mp4",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not return the HLS playlist when mp4 renditions exist", () => {
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
url: "https://video.twimg.com/playlist.m3u8",
|
|
||||||
content_type: "application/x-mpegURL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://video.twimg.com/only.mp4",
|
|
||||||
content_type: "video/mp4",
|
|
||||||
bitrate: 632000,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(pickBestVideoVariantUrl(variants)).toBe(
|
|
||||||
"https://video.twimg.com/only.mp4",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("falls back to the first variant when no mp4 is present", () => {
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
url: "https://video.twimg.com/playlist.m3u8",
|
|
||||||
content_type: "application/x-mpegURL",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(pickBestVideoVariantUrl(variants)).toBe(
|
|
||||||
"https://video.twimg.com/playlist.m3u8",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("detects mp4 by extension when content_type is absent", () => {
|
|
||||||
const variants = [
|
|
||||||
{ url: "https://video.twimg.com/240/vid.mp4?tag=12" },
|
|
||||||
{ url: "https://video.twimg.com/720/vid.mp4?tag=12", bitrate: 2176000 },
|
|
||||||
]
|
|
||||||
expect(pickBestVideoVariantUrl(variants)).toBe(
|
|
||||||
"https://video.twimg.com/720/vid.mp4?tag=12",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
/**
|
|
||||||
* Type definitions for the browser extension
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toast states for UI feedback
|
|
||||||
*/
|
|
||||||
export type ToastState = "loading" | "success" | "error"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Message types for extension communication
|
|
||||||
*/
|
|
||||||
export interface ExtensionMessage {
|
|
||||||
isFolderImport?: boolean
|
|
||||||
bookmarkCollectionId?: string
|
|
||||||
action?: string
|
|
||||||
type?: string
|
|
||||||
data?: unknown
|
|
||||||
state?: ToastState
|
|
||||||
importedMessage?: string
|
|
||||||
totalImported?: number
|
|
||||||
actionSource?: string
|
|
||||||
selectedProject?: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
containerTag: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memory data structure for saving content
|
|
||||||
*/
|
|
||||||
export interface MemoryData {
|
|
||||||
html?: string
|
|
||||||
markdown?: string
|
|
||||||
content?: string
|
|
||||||
highlightedText?: string
|
|
||||||
url?: string
|
|
||||||
ogImage?: string
|
|
||||||
title?: string
|
|
||||||
sourcePlatform?: string
|
|
||||||
sourcePlatformLabel?: string
|
|
||||||
sourceSurface?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supermemory API payload for storing memories
|
|
||||||
*/
|
|
||||||
export interface MemoryPayload {
|
|
||||||
containerTags?: string[]
|
|
||||||
content: string
|
|
||||||
metadata: {
|
|
||||||
sm_source: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
customId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Twitter-specific memory metadata
|
|
||||||
*/
|
|
||||||
export interface TwitterMemoryMetadata {
|
|
||||||
sm_source: "twitter_bookmarks"
|
|
||||||
tweet_id: string
|
|
||||||
author: string
|
|
||||||
created_at: string
|
|
||||||
likes: number
|
|
||||||
retweets: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage data structure for Chrome storage
|
|
||||||
*/
|
|
||||||
export interface StorageData {
|
|
||||||
bearerToken?: string
|
|
||||||
twitterAuth?: {
|
|
||||||
cookie: string
|
|
||||||
csrf: string
|
|
||||||
auth: string
|
|
||||||
}
|
|
||||||
tokens_logged?: boolean
|
|
||||||
cookie?: string
|
|
||||||
csrf?: string
|
|
||||||
auth?: string
|
|
||||||
defaultProject?: Project
|
|
||||||
projectsCache?: {
|
|
||||||
projects: Project[]
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context menu click info
|
|
||||||
*/
|
|
||||||
export interface ContextMenuClickInfo {
|
|
||||||
menuItemId: string | number
|
|
||||||
editable?: boolean
|
|
||||||
frameId?: number
|
|
||||||
frameUrl?: string
|
|
||||||
linkUrl?: string
|
|
||||||
mediaType?: string
|
|
||||||
pageUrl?: string
|
|
||||||
parentMenuItemId?: string | number
|
|
||||||
selectionText?: string
|
|
||||||
srcUrl?: string
|
|
||||||
targetElementId?: number
|
|
||||||
wasChecked?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API Response types
|
|
||||||
*/
|
|
||||||
export interface APIResponse<T = unknown> {
|
|
||||||
success: boolean
|
|
||||||
data?: T
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Error types for better error handling
|
|
||||||
*/
|
|
||||||
export class ExtensionError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public code?: string,
|
|
||||||
public statusCode?: number,
|
|
||||||
) {
|
|
||||||
super(message)
|
|
||||||
this.name = "ExtensionError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TwitterAPIError extends ExtensionError {
|
|
||||||
constructor(message: string, statusCode?: number) {
|
|
||||||
super(message, "TWITTER_API_ERROR", statusCode)
|
|
||||||
this.name = "TwitterAPIError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SupermemoryAPIError extends ExtensionError {
|
|
||||||
constructor(message: string, statusCode?: number) {
|
|
||||||
super(message, "SUPERMEMORY_API_ERROR", statusCode)
|
|
||||||
this.name = "SupermemoryAPIError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthenticationError extends ExtensionError {
|
|
||||||
constructor(message = "Authentication required") {
|
|
||||||
super(message, "AUTH_ERROR")
|
|
||||||
this.name = "AuthenticationError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Project {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
containerTag: string
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
documentCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectsResponse {
|
|
||||||
projects: Project[]
|
|
||||||
}
|
|
||||||
|
|
@ -1,791 +0,0 @@
|
||||||
/**
|
|
||||||
* UI Components Module
|
|
||||||
* Reusable UI components for the browser extension
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ELEMENT_IDS, UI_CONFIG } from "./constants"
|
|
||||||
import type { ToastState } from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a toast notification element
|
|
||||||
* @param state - The state of the toast (loading, success, error)
|
|
||||||
* @returns HTMLElement - The toast element
|
|
||||||
*/
|
|
||||||
export function createToast(state: ToastState): HTMLElement {
|
|
||||||
const toast = document.createElement("div")
|
|
||||||
toast.id = ELEMENT_IDS.SUPERMEMORY_TOAST
|
|
||||||
|
|
||||||
toast.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 2147483647;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 9999px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #374151;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 300px;
|
|
||||||
animation: slideIn 0.3s ease-out;
|
|
||||||
box-shadow: 0 4px 24px 0 rgba(0,0,0,0.18), 0 1.5px 6px 0 rgba(0,0,0,0.12);
|
|
||||||
`
|
|
||||||
|
|
||||||
// Add keyframe animations and fonts if not already present
|
|
||||||
if (!document.getElementById("supermemory-toast-styles")) {
|
|
||||||
const style = document.createElement("style")
|
|
||||||
style.id = "supermemory-toast-styles"
|
|
||||||
style.textContent = `
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Space Grotesk';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 300;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Light.ttf")}') format('truetype');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Space Grotesk';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Regular.ttf")}') format('truetype');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Space Grotesk';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Medium.ttf")}') format('truetype');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Space Grotesk';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-SemiBold.ttf")}') format('truetype');
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Space Grotesk';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Bold.ttf")}') format('truetype');
|
|
||||||
}
|
|
||||||
@keyframes slideIn {
|
|
||||||
from { transform: translateX(100%); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes fadeOut {
|
|
||||||
from { transform: translateX(0); opacity: 1; }
|
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
`
|
|
||||||
document.head.appendChild(style)
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = document.createElement("div")
|
|
||||||
icon.style.cssText = "width: 20px; height: 20px; flex-shrink: 0;"
|
|
||||||
|
|
||||||
let textElement: HTMLElement = document.createElement("span")
|
|
||||||
textElement.style.fontWeight = "500"
|
|
||||||
|
|
||||||
// Configure toast based on state
|
|
||||||
switch (state) {
|
|
||||||
case "loading":
|
|
||||||
icon.innerHTML = `
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12 6V2" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
<path d="M12 22V18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
|
|
||||||
<path d="M20.49 8.51L18.36 6.38" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
|
|
||||||
<path d="M5.64 17.64L3.51 15.51" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
|
|
||||||
<path d="M22 12H18" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.8"/>
|
|
||||||
<path d="M6 12H2" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
|
|
||||||
<path d="M20.49 15.49L18.36 17.62" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.9"/>
|
|
||||||
<path d="M5.64 6.36L3.51 8.49" stroke="#6366f1" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
icon.style.animation = "spin 1s linear infinite"
|
|
||||||
textElement.textContent = "Adding to Memory..."
|
|
||||||
break
|
|
||||||
|
|
||||||
case "success": {
|
|
||||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
|
||||||
icon.innerHTML = `<img src="${iconUrl}" width="20" height="20" alt="Success" style="border-radius: 2px;" />`
|
|
||||||
textElement.textContent = "Added to Memory"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case "error": {
|
|
||||||
icon.innerHTML = `
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
|
|
||||||
<path d="M15 9L9 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M9 9L15 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
const textContainer = document.createElement("div")
|
|
||||||
textContainer.style.cssText =
|
|
||||||
"display: flex; flex-direction: column; gap: 2px;"
|
|
||||||
|
|
||||||
const mainText = document.createElement("span")
|
|
||||||
mainText.style.cssText = "font-weight: 500; line-height: 1.2;"
|
|
||||||
mainText.textContent = "Failed to save memory"
|
|
||||||
|
|
||||||
const helperText = document.createElement("span")
|
|
||||||
helperText.style.cssText =
|
|
||||||
"font-size: 12px; color: #6b7280; font-weight: 400; line-height: 1.2;"
|
|
||||||
helperText.textContent = "Make sure you are logged in"
|
|
||||||
|
|
||||||
textContainer.appendChild(mainText)
|
|
||||||
textContainer.appendChild(helperText)
|
|
||||||
|
|
||||||
textElement = textContainer
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.appendChild(icon)
|
|
||||||
toast.appendChild(textElement)
|
|
||||||
|
|
||||||
return toast
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the Twitter import button
|
|
||||||
* @param onClick - Click handler for the button
|
|
||||||
* @returns HTMLElement - The button element
|
|
||||||
*/
|
|
||||||
export function createTwitterImportButton(onClick: () => void): HTMLElement {
|
|
||||||
const button = document.createElement("div")
|
|
||||||
button.id = ELEMENT_IDS.TWITTER_IMPORT_BUTTON
|
|
||||||
button.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 2147483646;
|
|
||||||
background: #ffffff;
|
|
||||||
color: black;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50px;
|
|
||||||
padding: 10px 16px 10px 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
`
|
|
||||||
|
|
||||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
|
||||||
|
|
||||||
button.style.backgroundImage = `url("${iconUrl}")`
|
|
||||||
button.style.backgroundRepeat = "no-repeat"
|
|
||||||
button.style.backgroundSize = "20px 20px"
|
|
||||||
button.style.backgroundPosition = "8px center"
|
|
||||||
|
|
||||||
const textSpan = document.createElement("span")
|
|
||||||
textSpan.id = "sm-import-text"
|
|
||||||
textSpan.style.cssText = "font-weight: 500; font-size: 12px;"
|
|
||||||
textSpan.textContent = "Import Bookmarks"
|
|
||||||
button.appendChild(textSpan)
|
|
||||||
|
|
||||||
button.addEventListener("mouseenter", () => {
|
|
||||||
button.style.opacity = "0.8"
|
|
||||||
button.style.boxShadow = "0 4px 12px rgba(29, 155, 240, 0.4)"
|
|
||||||
})
|
|
||||||
|
|
||||||
button.addEventListener("mouseleave", () => {
|
|
||||||
button.style.opacity = "1"
|
|
||||||
button.style.boxShadow = "0 2px 8px rgba(29, 155, 240, 0.3)"
|
|
||||||
})
|
|
||||||
|
|
||||||
button.addEventListener("click", onClick)
|
|
||||||
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a save tweet element button for Twitter/X
|
|
||||||
* @param onClick - Click handler for the button
|
|
||||||
* @returns HTMLElement - The save button element
|
|
||||||
*/
|
|
||||||
export function createSaveTweetElement(onClick: () => void): HTMLElement {
|
|
||||||
const iconButton = document.createElement("div")
|
|
||||||
iconButton.style.cssText = `
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 10px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
z-index: 1000;
|
|
||||||
`
|
|
||||||
|
|
||||||
const iconFileName = "/new_logo.png"
|
|
||||||
const iconUrl = browser.runtime.getURL(iconFileName)
|
|
||||||
iconButton.innerHTML = `
|
|
||||||
<img src="${iconUrl}" width="20" height="20" alt="Save to Memory" style="border-radius: 4px;" />
|
|
||||||
`
|
|
||||||
|
|
||||||
iconButton.addEventListener("mouseenter", () => {
|
|
||||||
iconButton.style.opacity = "1"
|
|
||||||
})
|
|
||||||
|
|
||||||
iconButton.addEventListener("mouseleave", () => {
|
|
||||||
iconButton.style.opacity = "0.7"
|
|
||||||
})
|
|
||||||
|
|
||||||
iconButton.addEventListener("click", (event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
event.preventDefault()
|
|
||||||
onClick()
|
|
||||||
})
|
|
||||||
|
|
||||||
return iconButton
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a save element button for ChatGPT input bar
|
|
||||||
* @param onClick - Click handler for the button
|
|
||||||
* @returns HTMLElement - The save button element
|
|
||||||
*/
|
|
||||||
export function createChatGPTInputBarElement(onClick: () => void): HTMLElement {
|
|
||||||
return createConnectedIndicator(onClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createConnectedIndicator(onClick: () => void): HTMLElement {
|
|
||||||
const iconButton = document.createElement("button")
|
|
||||||
iconButton.type = "button"
|
|
||||||
iconButton.setAttribute("aria-label", "supermemory connected")
|
|
||||||
iconButton.dataset.supermemoryConnectedIndicator = "true"
|
|
||||||
iconButton.style.cssText = `
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
min-width: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
`
|
|
||||||
|
|
||||||
const iconFileName = "/new_logo.png"
|
|
||||||
const iconUrl = browser.runtime.getURL(iconFileName)
|
|
||||||
iconButton.innerHTML = `
|
|
||||||
<img src="${iconUrl}" width="20" height="20" alt="" style="border-radius: 5px; display: block;" />
|
|
||||||
`
|
|
||||||
|
|
||||||
const tooltip = document.createElement("div")
|
|
||||||
tooltip.textContent = "supermemory connected"
|
|
||||||
tooltip.style.cssText = `
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 8px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%) translateY(2px);
|
|
||||||
background: #0A0E14;
|
|
||||||
color: #FAFAFA;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.16s ease, transform 0.16s ease;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
|
||||||
z-index: 2147483647;
|
|
||||||
`
|
|
||||||
iconButton.appendChild(tooltip)
|
|
||||||
|
|
||||||
iconButton.addEventListener("mouseenter", () => {
|
|
||||||
iconButton.style.backgroundColor = "rgba(255, 255, 255, 0.08)"
|
|
||||||
tooltip.style.opacity = "1"
|
|
||||||
tooltip.style.transform = "translateX(-50%) translateY(0)"
|
|
||||||
})
|
|
||||||
|
|
||||||
iconButton.addEventListener("mouseleave", () => {
|
|
||||||
iconButton.style.backgroundColor = "transparent"
|
|
||||||
tooltip.style.opacity = "0"
|
|
||||||
tooltip.style.transform = "translateX(-50%) translateY(2px)"
|
|
||||||
})
|
|
||||||
|
|
||||||
iconButton.addEventListener("click", (event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
event.preventDefault()
|
|
||||||
onClick()
|
|
||||||
})
|
|
||||||
|
|
||||||
return iconButton
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a save element button for Claude input bar
|
|
||||||
* @param onClick - Click handler for the button
|
|
||||||
* @returns HTMLElement - The save button element
|
|
||||||
*/
|
|
||||||
export function createClaudeInputBarElement(onClick: () => void): HTMLElement {
|
|
||||||
return createConnectedIndicator(onClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGeminiInputBarElement(onClick: () => void): HTMLElement {
|
|
||||||
return createConnectedIndicator(onClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a save element button for T3.chat input bar
|
|
||||||
* @param onClick - Click handler for the button
|
|
||||||
* @returns HTMLElement - The save button element
|
|
||||||
*/
|
|
||||||
export function createT3InputBarElement(onClick: () => void): HTMLElement {
|
|
||||||
const iconButton = document.createElement("div")
|
|
||||||
iconButton.style.cssText = `
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: auto;
|
|
||||||
height: 32px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
`
|
|
||||||
|
|
||||||
const iconFileName = "/new_logo.png"
|
|
||||||
const iconUrl = browser.runtime.getURL(iconFileName)
|
|
||||||
iconButton.innerHTML = `
|
|
||||||
<img src="${iconUrl}" width="20" height="20" alt="Get Related Memories from supermemory" style="border-radius: 4px;" />
|
|
||||||
`
|
|
||||||
|
|
||||||
iconButton.addEventListener("mouseenter", () => {
|
|
||||||
iconButton.style.backgroundColor = "rgba(0, 0, 0, 0.05)"
|
|
||||||
iconButton.style.borderColor = "rgba(0, 0, 0, 0.2)"
|
|
||||||
})
|
|
||||||
|
|
||||||
iconButton.addEventListener("mouseleave", () => {
|
|
||||||
iconButton.style.backgroundColor = "transparent"
|
|
||||||
iconButton.style.borderColor = "rgba(0, 0, 0, 0.1)"
|
|
||||||
})
|
|
||||||
|
|
||||||
iconButton.addEventListener("click", (event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
event.preventDefault()
|
|
||||||
onClick()
|
|
||||||
})
|
|
||||||
|
|
||||||
return iconButton
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a project selection modal for Twitter folder imports
|
|
||||||
* @param projects - Array of available projects
|
|
||||||
* @param onImport - Callback when import is clicked with selected project
|
|
||||||
* @param onClose - Callback when modal is closed
|
|
||||||
* @returns HTMLElement - The modal element
|
|
||||||
*/
|
|
||||||
export function createProjectSelectionModal(
|
|
||||||
projects: Array<{ id: string; name: string; containerTag: string }>,
|
|
||||||
onImport: (project: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
containerTag: string
|
|
||||||
}) => void,
|
|
||||||
onClose: () => void,
|
|
||||||
): HTMLElement {
|
|
||||||
const modal = document.createElement("div")
|
|
||||||
modal.id = "sm-project-selection-modal"
|
|
||||||
modal.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 2147483648;
|
|
||||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
`
|
|
||||||
|
|
||||||
const dialog = document.createElement("div")
|
|
||||||
dialog.style.cssText = `
|
|
||||||
background: #05070A;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 8px 32px rgba(5, 7, 10, 0.2);
|
|
||||||
position: relative;
|
|
||||||
`
|
|
||||||
|
|
||||||
const header = document.createElement("div")
|
|
||||||
header.style.cssText = `
|
|
||||||
margin-bottom: 20px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
|
||||||
header.innerHTML = `
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
|
||||||
<h3 style="margin: 0; font-size: 16px; font-weight: 600; color: #ffffff; display: flex; align-items: center; gap: 8px;">
|
|
||||||
<img src="${iconUrl}" width="20" height="20" alt="Supermemory" style="border-radius: 4px;" />
|
|
||||||
Import to Supermemory
|
|
||||||
</h3>
|
|
||||||
<p style="margin: 0; font-size: 14px; font-weight: 400; color: #ffffff; opacity: 0.7;">
|
|
||||||
The project you want to import your bookmarks to.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
const form = document.createElement("div")
|
|
||||||
form.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const selectContainer = document.createElement("div")
|
|
||||||
selectContainer.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const label = document.createElement("label")
|
|
||||||
label.style.cssText = `
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #ffffff;
|
|
||||||
`
|
|
||||||
label.textContent = "Select Project to import"
|
|
||||||
|
|
||||||
const select = document.createElement("select")
|
|
||||||
select.id = "project-select"
|
|
||||||
select.style.cssText = `
|
|
||||||
padding: 12px 40px 12px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
background: rgba(91, 126, 245, 0.04);
|
|
||||||
box-shadow: -1px -1px 1px 0 rgba(82, 89, 102, 0.08) inset, 2px 2px 1px 0 rgba(0, 0, 0, 0.50) inset;
|
|
||||||
color: #ffffff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 16px center;
|
|
||||||
background-size: 16px;
|
|
||||||
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
`
|
|
||||||
select.addEventListener("focus", () => {
|
|
||||||
select.style.borderColor = "#1A88FF"
|
|
||||||
})
|
|
||||||
select.addEventListener("blur", () => {
|
|
||||||
select.style.borderColor = "#374151"
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add default option
|
|
||||||
const defaultOption = document.createElement("option")
|
|
||||||
defaultOption.value = ""
|
|
||||||
defaultOption.textContent = "Choose a project..."
|
|
||||||
defaultOption.disabled = true
|
|
||||||
defaultOption.selected = true
|
|
||||||
select.appendChild(defaultOption)
|
|
||||||
|
|
||||||
// Add project options
|
|
||||||
projects.forEach((project) => {
|
|
||||||
const option = document.createElement("option")
|
|
||||||
option.value = project.id
|
|
||||||
option.textContent = project.name
|
|
||||||
option.dataset.containerTag = project.containerTag
|
|
||||||
select.appendChild(option)
|
|
||||||
})
|
|
||||||
|
|
||||||
const buttonContainer = document.createElement("div")
|
|
||||||
buttonContainer.style.cssText = `
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 8px;
|
|
||||||
`
|
|
||||||
|
|
||||||
const cancelButton = document.createElement("button")
|
|
||||||
cancelButton.textContent = "Cancel"
|
|
||||||
cancelButton.style.cssText = `
|
|
||||||
padding: 10px 16px;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: none;
|
|
||||||
background: #05070A;
|
|
||||||
`
|
|
||||||
cancelButton.addEventListener("mouseenter", () => {
|
|
||||||
cancelButton.style.backgroundColor = "#f9fafb"
|
|
||||||
cancelButton.style.color = "#05070A"
|
|
||||||
})
|
|
||||||
cancelButton.addEventListener("mouseleave", () => {
|
|
||||||
cancelButton.style.backgroundColor = "#05070A"
|
|
||||||
cancelButton.style.color = "#ffffff"
|
|
||||||
})
|
|
||||||
|
|
||||||
const importButton = document.createElement("button")
|
|
||||||
importButton.textContent = "Import"
|
|
||||||
importButton.style.cssText = `
|
|
||||||
padding: 10px 16px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
`
|
|
||||||
importButton.disabled = true
|
|
||||||
|
|
||||||
// Handle project selection
|
|
||||||
select.addEventListener("change", () => {
|
|
||||||
const selectedOption = select.options[select.selectedIndex]
|
|
||||||
if (selectedOption.value) {
|
|
||||||
importButton.disabled = false
|
|
||||||
importButton.style.cssText = `
|
|
||||||
padding: 10px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: linear-gradient(203deg, #0FF0D2 -49.88%, #5BD3FB -33.14%, #1E0FF0 81.81%);
|
|
||||||
box-shadow: 1px 1px 2px 1px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20);
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
`
|
|
||||||
} else {
|
|
||||||
importButton.disabled = true
|
|
||||||
importButton.style.cssText = `
|
|
||||||
padding: 10px 16px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle import button click
|
|
||||||
importButton.addEventListener("click", () => {
|
|
||||||
const selectedOption = select.options[select.selectedIndex]
|
|
||||||
if (selectedOption.value) {
|
|
||||||
const selectedProject = {
|
|
||||||
id: selectedOption.value,
|
|
||||||
name: selectedOption.textContent,
|
|
||||||
containerTag: selectedOption.dataset.containerTag || "",
|
|
||||||
}
|
|
||||||
onImport(selectedProject)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle cancel button click
|
|
||||||
cancelButton.addEventListener("click", onClose)
|
|
||||||
|
|
||||||
// Handle overlay click to close
|
|
||||||
modal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === modal) {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle escape key
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
|
||||||
|
|
||||||
// Clean up event listener when modal is removed
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
if (!document.contains(modal)) {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true })
|
|
||||||
|
|
||||||
selectContainer.appendChild(label)
|
|
||||||
selectContainer.appendChild(select)
|
|
||||||
form.appendChild(selectContainer)
|
|
||||||
buttonContainer.appendChild(cancelButton)
|
|
||||||
buttonContainer.appendChild(importButton)
|
|
||||||
form.appendChild(buttonContainer)
|
|
||||||
|
|
||||||
dialog.appendChild(header)
|
|
||||||
dialog.appendChild(form)
|
|
||||||
modal.appendChild(dialog)
|
|
||||||
|
|
||||||
return modal
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility functions for DOM manipulation
|
|
||||||
*/
|
|
||||||
export const DOMUtils = {
|
|
||||||
/**
|
|
||||||
* Check if current page is on specified domains
|
|
||||||
* @param domains - Array of domain names to check
|
|
||||||
* @returns boolean
|
|
||||||
*/
|
|
||||||
isOnDomain(domains: readonly string[]): boolean {
|
|
||||||
return domains.includes(window.location.hostname)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if the page is in dark mode based on color-scheme style
|
|
||||||
* @returns boolean - true if dark mode, false if light mode
|
|
||||||
*/
|
|
||||||
isDarkMode(): boolean {
|
|
||||||
const htmlElement = document.documentElement
|
|
||||||
const style = htmlElement.getAttribute("style")
|
|
||||||
return style?.includes("color-scheme: dark") || false
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if element exists in DOM
|
|
||||||
* @param id - Element ID to check
|
|
||||||
* @returns boolean
|
|
||||||
*/
|
|
||||||
elementExists(id: string): boolean {
|
|
||||||
return !!document.getElementById(id)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove element from DOM if it exists
|
|
||||||
* @param id - Element ID to remove
|
|
||||||
*/
|
|
||||||
removeElement(id: string): void {
|
|
||||||
const element = document.getElementById(id)
|
|
||||||
element?.remove()
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show toast notification with auto-dismiss
|
|
||||||
* @param state - Toast state
|
|
||||||
* @param duration - Duration to show toast (default from config)
|
|
||||||
* @returns The toast element
|
|
||||||
*/
|
|
||||||
showToast(
|
|
||||||
state: ToastState,
|
|
||||||
duration: number = UI_CONFIG.TOAST_DURATION,
|
|
||||||
): HTMLElement {
|
|
||||||
const existingToast = document.getElementById(ELEMENT_IDS.SUPERMEMORY_TOAST)
|
|
||||||
|
|
||||||
if ((state === "success" || state === "error") && existingToast) {
|
|
||||||
const icon = existingToast.querySelector("div")
|
|
||||||
const text = existingToast.querySelector("span")
|
|
||||||
|
|
||||||
if (icon && text) {
|
|
||||||
if (state === "success") {
|
|
||||||
const iconUrl = browser.runtime.getURL("/new_logo.png")
|
|
||||||
icon.innerHTML = `<img src="${iconUrl}" width="20" height="20" alt="Success" style="border-radius: 2px;" />`
|
|
||||||
icon.style.animation = ""
|
|
||||||
text.textContent = "Added to Memory"
|
|
||||||
} else if (state === "error") {
|
|
||||||
icon.innerHTML = `
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
|
|
||||||
<path d="M15 9L9 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M9 9L15 15" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
icon.style.animation = ""
|
|
||||||
|
|
||||||
const textContainer = document.createElement("div")
|
|
||||||
textContainer.style.cssText =
|
|
||||||
"display: flex; flex-direction: column; gap: 2px;"
|
|
||||||
|
|
||||||
const mainText = document.createElement("span")
|
|
||||||
mainText.style.cssText = "font-weight: 500; line-height: 1.2;"
|
|
||||||
mainText.textContent = "Failed to save memory"
|
|
||||||
|
|
||||||
const helperText = document.createElement("span")
|
|
||||||
helperText.style.cssText =
|
|
||||||
"font-size: 12px; color: #6b7280; font-weight: 400; line-height: 1.2;"
|
|
||||||
helperText.textContent = "Make sure you are logged in"
|
|
||||||
|
|
||||||
textContainer.appendChild(mainText)
|
|
||||||
textContainer.appendChild(helperText)
|
|
||||||
|
|
||||||
text.innerHTML = ""
|
|
||||||
text.appendChild(textContainer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-dismiss
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(existingToast)) {
|
|
||||||
existingToast.style.animation = "fadeOut 0.3s ease-out"
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(existingToast)) {
|
|
||||||
existingToast.remove()
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
}, duration)
|
|
||||||
|
|
||||||
return existingToast
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingToasts = document.querySelectorAll(
|
|
||||||
`#${ELEMENT_IDS.SUPERMEMORY_TOAST}`,
|
|
||||||
)
|
|
||||||
existingToasts.forEach((toast) => {
|
|
||||||
toast.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
const toast = createToast(state)
|
|
||||||
document.body.appendChild(toast)
|
|
||||||
|
|
||||||
// Auto-dismiss for success and error states
|
|
||||||
if (state === "success" || state === "error") {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(toast)) {
|
|
||||||
toast.style.animation = "fadeOut 0.3s ease-out"
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(toast)) {
|
|
||||||
toast.remove()
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
|
||||||
}, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
return toast
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import path from "node:path"
|
|
||||||
import { createRequire } from "node:module"
|
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
|
||||||
import { defineConfig, type WxtViteConfig } from "wxt"
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
|
||||||
|
|
||||||
function reactPackageRoot(pkg: "react" | "react-dom"): string {
|
|
||||||
return path.dirname(require.resolve(`${pkg}/package.json`))
|
|
||||||
}
|
|
||||||
|
|
||||||
// See https://wxt.dev/api/config.html
|
|
||||||
export default defineConfig({
|
|
||||||
modules: ["@wxt-dev/module-react"],
|
|
||||||
vite: () =>
|
|
||||||
({
|
|
||||||
plugins: [tailwindcss()],
|
|
||||||
resolve: {
|
|
||||||
dedupe: ["react", "react-dom"],
|
|
||||||
alias: {
|
|
||||||
react: reactPackageRoot("react"),
|
|
||||||
"react-dom": reactPackageRoot("react-dom"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
optimizeDeps: {
|
|
||||||
include: ["react", "react-dom", "@tanstack/react-query"],
|
|
||||||
},
|
|
||||||
}) as WxtViteConfig,
|
|
||||||
manifest: {
|
|
||||||
name: "supermemory",
|
|
||||||
homepage_url: "https://supermemory.ai",
|
|
||||||
version: "6.1.3",
|
|
||||||
permissions: ["storage", "activeTab", "webRequest", "tabs"],
|
|
||||||
host_permissions: [
|
|
||||||
"*://x.com/*",
|
|
||||||
"*://twitter.com/*",
|
|
||||||
"*://supermemory.ai/*",
|
|
||||||
"*://api.supermemory.ai/*",
|
|
||||||
"*://chatgpt.com/*",
|
|
||||||
"*://chat.openai.com/*",
|
|
||||||
"*://grok.com/*",
|
|
||||||
"*://*.grok.com/*",
|
|
||||||
"*://x.ai/*",
|
|
||||||
"*://*.x.ai/*",
|
|
||||||
"*://claude.ai/*",
|
|
||||||
"*://gemini.google.com/*",
|
|
||||||
"*://t3.chat/*",
|
|
||||||
"https://*.posthog.com/*",
|
|
||||||
],
|
|
||||||
web_accessible_resources: [
|
|
||||||
{
|
|
||||||
resources: ["new_logo.png", "fonts/*.ttf"],
|
|
||||||
matches: ["<all_urls>"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
webExt: {
|
|
||||||
chromiumArgs: ["--user-data-dir=./.wxt/chrome-data"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
---
|
|
||||||
title: "Automations and Proactiveness"
|
|
||||||
sidebarTitle: "Automations"
|
|
||||||
description: "Scheduled work Company Brain runs on its own, and when it speaks without being asked"
|
|
||||||
icon: "bot"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention, ChannelRef } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
Company Brain doesn't only answer when you @mention it. It can run recurring work on a schedule, and it can speak in a thread on its own when it has something genuinely worth saying. Both are opt-in, both are rate-limited, and both read from exactly the same [permissions graph](/company-brain/permissions) as a normal question — neither is a backdoor around it.
|
|
||||||
|
|
||||||
## Automations
|
|
||||||
|
|
||||||
An automation is a prompt that runs on a schedule and posts the result somewhere. You write it once, in plain language:
|
|
||||||
|
|
||||||
<SlackThread channel="#product">
|
|
||||||
<SlackMessage self hasAvatar time="9:03 AM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> every Monday at 9am, post a digest of what shipped last week and what's still open, to <ChannelRef>product</ChannelRef>.
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="9:03 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Got it — scheduled. First digest posts Monday, 9:00 AM, to <ChannelRef>product</ChannelRef>.
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Schedule fires">
|
|
||||||
The automation wakes up at its set time — no one has to trigger it.
|
|
||||||
</Step>
|
|
||||||
<Step title="Gathers context">
|
|
||||||
It reads using only **org-shared** connections and channel memory — never a person's personal credentials, even if the person who created the automation has better personal access. This is what keeps a scheduled post from silently acting as a specific teammate.
|
|
||||||
</Step>
|
|
||||||
<Step title="Checks visibility">
|
|
||||||
Before posting, it re-confirms it can still see the destination channel.
|
|
||||||
</Step>
|
|
||||||
<Step title="Posts, or fails closed">
|
|
||||||
If anything above is unclear — a connection broke, visibility can't be verified — it skips that run rather than posting a guess. Silence beats a wrong digest.
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
**Who can target what:**
|
|
||||||
|
|
||||||
| Destination | Who can create it | Reads from |
|
|
||||||
|---|---|---|
|
|
||||||
| Public channel | Any member | Org-shared connections, public channel memory |
|
|
||||||
| Private channel | Admins only | Org-shared connections, that channel's memory |
|
|
||||||
| DM to yourself | The owner of that DM | Your personal + org connections, your employee memory |
|
|
||||||
|
|
||||||
Common shapes worth stealing:
|
|
||||||
|
|
||||||
- A Monday-morning digest of open items and unanswered questions
|
|
||||||
- A daily Sentry error recap in `#eng`
|
|
||||||
- A weekly "what changed across our connected tools" summary
|
|
||||||
|
|
||||||
Anyone can create and manage their own automations; admins can manage everyone's. Ask Company Brain in Slack to set one up, or manage the full list from the web app.
|
|
||||||
|
|
||||||
## Proactiveness (chime-in)
|
|
||||||
|
|
||||||
Chime-in is different from an automation: there's no schedule, and no one asked. Company Brain is simply present in a channel — because an admin invited it — and it speaks up when staying quiet would waste someone's time.
|
|
||||||
|
|
||||||
**What actually earns a chime-in:**
|
|
||||||
|
|
||||||
- It has to add something the room doesn't already have — a fact, a correction, a next step — not agreement or a restatement of what's already visible.
|
|
||||||
- It has to come from somewhere it's genuinely allowed to look: [connected tools](/company-brain/connectors) or that room's own memory, same as any other answer.
|
|
||||||
- If it isn't confident the answer is actually correct, it says nothing. A wrong guess is worse than silence, so uncertainty resolves to silence, not a hedge.
|
|
||||||
|
|
||||||
<CodeGroup>
|
|
||||||
```text Worth chiming in
|
|
||||||
"is prod down? customers are pinging me"
|
|
||||||
→ correlates against Sentry, replies with what's actually elevated right now
|
|
||||||
```
|
|
||||||
|
|
||||||
```text Not worth it
|
|
||||||
"finally shipped this 🎉" (screenshot, no question)
|
|
||||||
→ stays quiet — there's nothing to add
|
|
||||||
```
|
|
||||||
</CodeGroup>
|
|
||||||
|
|
||||||
**Guardrails that keep it from becoming noise:**
|
|
||||||
|
|
||||||
- **Rate-limited.** It won't speak repeatedly in the same thread or channel in a short window, even if it technically could add something each time.
|
|
||||||
- **Invite-only rooms.** It never joins a channel on its own — only places an admin already invited it into.
|
|
||||||
- **Same graph as a normal answer.** A private channel's chime-in only ever draws on that channel's memory and public channel memory — never another private channel, never someone else's employee memory.
|
|
||||||
|
|
||||||
An explicit @mention always skips this judgment call entirely — naming it is you deciding it should speak, so it does.
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
Automations and chime-in both write back to memory the same way a normal conversation does: a public channel's automation output lands in public channel memory, a private channel's chime-in stays scoped to that channel's memory.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="What you can do" icon="sparkles" href="/company-brain/use-cases/overview">
|
|
||||||
Real scenarios — support, incidents, digests, and more.
|
|
||||||
</Card>
|
|
||||||
<Card title="Connectors" icon="plug" href="/company-brain/connectors">
|
|
||||||
Wire up the tools automations and chime-in draw from.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
---
|
|
||||||
title: "Connectors"
|
|
||||||
sidebarTitle: "Connectors"
|
|
||||||
description: "Bring knowledge in with data connectors, and act in live tools with tool connectors"
|
|
||||||
icon: "plug"
|
|
||||||
---
|
|
||||||
|
|
||||||
Company Brain has two kinds of connectors. They look similar on the connections page, but they do different jobs:
|
|
||||||
|
|
||||||
| | Data connectors | Tool connectors |
|
|
||||||
|---|---|---|
|
|
||||||
| **What they do** | Bring knowledge *in* | Let the agent *act* in the tool |
|
|
||||||
| **Examples** | Google Drive, Notion, OneDrive | GitHub, Linear, Sentry, Plain, PostHog, Granola |
|
|
||||||
| **Result** | Docs land in public channel memory and stay searchable | Live reads and writes (list PRs, create issues, check errors) |
|
|
||||||
| **When it runs** | Background sync on a schedule | In the moment you ask |
|
|
||||||
|
|
||||||
## Data connectors
|
|
||||||
|
|
||||||
Data connectors sync existing files and docs into **public channel memory** so answers are grounded in real material — roadmaps, specs, handbooks, design docs.
|
|
||||||
|
|
||||||
How it works:
|
|
||||||
|
|
||||||
1. An admin connects a source (Drive, Notion workspace, OneDrive, and similar).
|
|
||||||
2. Company Brain fetches, chunks, embeds, and indexes the content in the background.
|
|
||||||
3. It re-syncs on a schedule automatically — you don't re-upload when a doc changes.
|
|
||||||
|
|
||||||
Connecting a data source is a **team-level action**. What comes in is visible org-wide, same as anything from a public channel — see the [permissions graph](/company-brain/permissions) for exactly who can read what.
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
A data connector is only as useful as the docs you point it at. Start with the handful of sources people actually re-read — product specs, the handbook, the latest roadmap — rather than every folder in Drive.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Tool connectors
|
|
||||||
|
|
||||||
Tool connectors are live integrations (MCP-based under the hood). They don't just index past content — they read and act in the tool *right now*:
|
|
||||||
|
|
||||||
- **GitHub** — open PRs, recent commits, repo context
|
|
||||||
- **Linear** — find or create issues, check status
|
|
||||||
- **Sentry** — what's actually erroring in prod
|
|
||||||
- **Plain** — customer support tickets and history
|
|
||||||
- **PostHog** — product analytics
|
|
||||||
- **Granola** — meeting notes and decisions
|
|
||||||
- **Custom servers** — wire up your own MCP endpoint when the catalog doesn't cover a tool
|
|
||||||
|
|
||||||
You can also connect tools at two scopes — **Organization (shared)** or **Personal (yours)**. The full rule of thumb lives on [The permissions graph](/company-brain/permissions): reads prefer your personal connection and fall back to the org one; writes always run under your own account so the action is attributed to you.
|
|
||||||
|
|
||||||
If neither you nor the org has a tool connected, but a teammate does, Company Brain can ask them to **lease** temporary access for that one request — see [Leasing](/company-brain/permissions#leasing-borrowing-access-for-one-request).
|
|
||||||
|
|
||||||
## Which one do I need?
|
|
||||||
|
|
||||||
- **"What's in our Q2 roadmap?"** → data connector (Drive/Notion/OneDrive already synced)
|
|
||||||
- **"What are my open PRs?"** or **"Create a Linear issue"** → tool connector (GitHub / Linear)
|
|
||||||
- **"What did we decide in the Acme call?"** → tool connector that also brings knowledge in (Granola), or a data connector if notes live in Drive/Notion
|
|
||||||
|
|
||||||
You almost always want both: data connectors for the long-lived knowledge base, tool connectors for the live work happening this week.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Automations & proactiveness" icon="wand-magic-sparkles" href="/company-brain/automations">
|
|
||||||
Scheduled digests and unprompted replies that use these connections.
|
|
||||||
</Card>
|
|
||||||
<Card title="What you can do" icon="sparkles" href="/company-brain/use-cases/overview">
|
|
||||||
Walkthroughs of support, incidents, PRs, meetings, and more.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
---
|
|
||||||
title: "Using Outside Slack"
|
|
||||||
sidebarTitle: "Outside Slack"
|
|
||||||
description: "Reach the same permissions graph from Claude Code, ChatGPT, Cursor, or any MCP client"
|
|
||||||
icon: "globe"
|
|
||||||
---
|
|
||||||
|
|
||||||
Slack is the default surface, not the only one. Company Brain speaks MCP, so the same graph — your employee memory, the private channels you're in, public channel memory — is reachable from any MCP client: Claude Code, ChatGPT, Cursor, or anything else that speaks the protocol.
|
|
||||||
|
|
||||||
## Connect
|
|
||||||
|
|
||||||
Same endpoint as [Supermemory MCP](/supermemory-mcp/mcp) — there's no separate Company Brain server to point at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://mcp.supermemory.ai/mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
OAuth by default — your client discovers the authorization server and prompts you to sign in. Prefer an API key instead? Any key starting with `sm_` skips OAuth entirely.
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
What changes isn't the URL, it's what shows up once you're connected. If your account belongs to an org with Company Brain, you get more than your own project spaces — your employee memory, the private channels you're in, and public channel memory all become available as workspaces, carrying your role and the exact same read/write access Slack already enforces.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## Pick a workspace
|
|
||||||
|
|
||||||
Once connected, ask it what's available — it returns every container tag you have access to: your employee memory, each private channel memory you belong to, and public channel memory. Select one to make it the active workspace for the session; everything after that scopes to it automatically.
|
|
||||||
|
|
||||||
**Example:** from Claude Code, "what can I access in Acme's Company Brain?" surfaces your options as a picker — your employee memory, `#eng`'s private channel memory if you're in it, public channel memory. Pick one, and every search or save for the rest of the session happens inside it — the same as asking from that room in Slack.
|
|
||||||
|
|
||||||
## Tools
|
|
||||||
|
|
||||||
| Tool | What it does |
|
|
||||||
|---|---|
|
|
||||||
| `listContainerTags` | Everything you're allowed to read, with names and counts |
|
|
||||||
| `select-workspace` / `set-active-tag` | Pick which one is active for this session |
|
|
||||||
| `recall` | Search the active workspace, plus a profile summary when you're in your employee memory |
|
|
||||||
| `save-memory` | Write back to the active workspace |
|
|
||||||
| `memory-graph` | An interactive, visual map of a workspace's memories |
|
|
||||||
| `whoAmI` | Your role, access type, and active workspace — useful for sanity-checking what a client can actually see |
|
|
||||||
|
|
||||||
## Same graph, same guardrails
|
|
||||||
|
|
||||||
Nothing here is a side door. What you can reach follows the exact same [permissions graph](/company-brain/permissions) as Slack — an admin can restrict a member's connection to specific container tags the same way they'd scope a Slack channel invite, and every read or write is checked against that before it runs.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="The permissions graph" icon="shield-check" href="/company-brain/permissions">
|
|
||||||
What each container tag actually is, and who can read it.
|
|
||||||
</Card>
|
|
||||||
<Card title="Supermemory MCP" icon="brain-circuit" href="/supermemory-mcp/mcp">
|
|
||||||
Base setup, auth, and personal project spaces on the same server.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
---
|
|
||||||
title: "What is Supermemory Company Brain?"
|
|
||||||
sidebarTitle: "Overview"
|
|
||||||
description: "A super agent, with all the knowledge and tools of your team"
|
|
||||||
icon: "brain"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention, ChannelRef, AgentLink } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
Supermemory Company Brain is a super agent with shared memory for your team that you can **ask questions** and that can **act in your tools**. It pulls from the work you already do - Slack threads, docs, GitHub, Linear - And can pretty much act as a real employee in your team.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Remembers" icon="brain">
|
|
||||||
Meetings, decisions, projects, and who owns what — kept current as your connected sources sync.
|
|
||||||
</Card>
|
|
||||||
<Card title="Acts" icon="bolt">
|
|
||||||
Pulls your open PRs, finds a Linear issue, or answers in a Slack channel — not just recites text back.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
||||||
## A quick example of its capabilities
|
|
||||||
|
|
||||||
It'll answer a question, actually go do the work, or just jump into a conversation on its own when it's got something worth saying — like a coworker would, not a search bar. You can configure how funny and unhinged it is, or how of it you want.
|
|
||||||
|
|
||||||
### Ask it anything
|
|
||||||
|
|
||||||
<SlackThread channel="#product">
|
|
||||||
<SlackMessage self hasAvatar time="10:42 AM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> what did we decide about pricing for the company brain
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="10:42 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
You and mahesh decided yesterday that it stays at **$400/mo**, usage based only. we landed on that in a <ChannelRef>product</ChannelRef> huddle
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
### Put it to work
|
|
||||||
|
|
||||||
<SlackThread channel="#eng">
|
|
||||||
<SlackMessage self hasAvatar time="3:24 PM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> is the Stripe webhook timeout from Sentry already tracked anywhere?
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="3:25 PM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
nope, nothing yet — just opened **ENG-847** for it, added the sentry issue to it, and put it on <Mention>Sam</Mention> since they were the last one in `webhooks/stripe.ts` and their beautiful code broke it ☠️. <AgentLink href="https://linear.app">here you go</AgentLink>, should probably fix it asap.
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
### Let it speak up on its own
|
|
||||||
|
|
||||||
<SlackThread channel="#eng" members={48}>
|
|
||||||
<SlackMessage name="Alex" color="#E01E5A" time="11:03 AM">
|
|
||||||
is prod down? a couple of customers are pinging me
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="11:03 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
not fully down — `api/search` is just elevated, 42 errors in the last 15 min (SM-2041), and <Mention>Kush</Mention> is on it. probably that deploy from this morning. Only one user has complained on support and i already replied to them saying it's being investigated.
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
You don't need to mention it. It speaks up when it has something to add. It's smart and proactive!
|
|
||||||
|
|
||||||
## Same knowledge, useful everywhere
|
|
||||||
|
|
||||||
It's your team's knowledge — it doesn't have to stay in Slack. Take it wherever you're actually working:
|
|
||||||
|
|
||||||
- **Your coding agent** — ask Claude Code or Cursor mid-session what the team decided, why a file looks the way it does, or who to ping about it, without tabbing over to Slack.
|
|
||||||
- **Your own tools, via MCP** — Company Brain speaks MCP, so if whatever you're building can speak MCP too, it can ask. Plug it into an internal tool, a script, whatever you need.
|
|
||||||
|
|
||||||
Same permissions graph everywhere, no exceptions — asking from Claude Code doesn't get you anything asking from Slack wouldn't.
|
|
||||||
|
|
||||||
```text
|
|
||||||
> is the stripe webhook thing from earlier actually fixed?
|
|
||||||
yep — Sam shipped it in ENG-847 about an hour ago, Sentry's been quiet since
|
|
||||||
```
|
|
||||||
|
|
||||||
This knowledge can be used wherever you and your teammates go — see [Using outside Slack](/company-brain/outside-slack) for how to connect.
|
|
||||||
|
|
||||||
## Use it your way
|
|
||||||
|
|
||||||
Company Brain isn't locked to one model or one voice. Two things you control directly:
|
|
||||||
|
|
||||||
- **Any model, no markup** — bring your own LLM and pay nothing extra for inference.
|
|
||||||
- **Its tonality** — configure how it talks, from buttoned-up professional to fully unhinged. Make it sound like your team, not a generic chatbot.
|
|
||||||
|
|
||||||
## Where to go next
|
|
||||||
|
|
||||||
Company Brain has a handful of ideas worth understanding before you set it up: Our permissioning setup, how to configure it, proactiveness, automations, and more.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="The permissions graph" icon="shield-check" href="/company-brain/permissions">
|
|
||||||
What's remembered where, and who can read it.
|
|
||||||
</Card>
|
|
||||||
<Card title="Setup and onboarding" icon="rocket" href="/company-brain/setup">
|
|
||||||
Get your team's workspace running.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
---
|
|
||||||
title: "The Permissions Graph"
|
|
||||||
sidebarTitle: "Permissions"
|
|
||||||
description: "What Company Brain remembers, who it's visible to, and how tool access is scoped"
|
|
||||||
icon: "shield-check"
|
|
||||||
---
|
|
||||||
|
|
||||||
Company Brain isn't split into "a shared brain" and "a private brain." It's a graph: memory is written to the narrowest room a conversation happened in, and what a given conversation can *read* depends on where it's happening and who's asking. Nothing here is silent — every install, channel read, and temporary access grant requires an explicit accept from a real person.
|
|
||||||
|
|
||||||
## Three memories, not two
|
|
||||||
|
|
||||||
<CardGroup cols={3}>
|
|
||||||
<Card title="Employee memory" icon="user">
|
|
||||||
One per person. Built from your DMs with the bot and what it learns about you over time. Only visible from your own DM.
|
|
||||||
</Card>
|
|
||||||
<Card title="Private channel memory" icon="lock">
|
|
||||||
One per private channel. Scoped to that room — visible to anyone in it, to no one outside it.
|
|
||||||
</Card>
|
|
||||||
<Card title="Public channel memory" icon="hash">
|
|
||||||
One per organization. Anything durable from a public channel lands here. The whole org can draw on it.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
||||||
A message writes to exactly one of these — whichever room it happened in.
|
|
||||||
|
|
||||||
## What a conversation can read
|
|
||||||
|
|
||||||
Writing is narrow; reading is broader, and it widens the more private the room is:
|
|
||||||
|
|
||||||
| Asking from | Can read |
|
|
||||||
|---|---|
|
|
||||||
| A public channel | Public channel memory |
|
|
||||||
| A private channel | That channel's memory + public channel memory |
|
|
||||||
| A DM with the bot | Your employee memory + public channel memory + every private channel memory you belong to |
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
Pub["Public channel memory<br/>(the whole org)"]
|
|
||||||
Priv["Private channel memory<br/>(that room's members)"]
|
|
||||||
Emp["Employee memory<br/>(you, in DM)"]
|
|
||||||
|
|
||||||
Priv -.reads.-> Pub
|
|
||||||
Emp -.reads.-> Pub
|
|
||||||
Emp -.reads.-> Priv
|
|
||||||
```
|
|
||||||
|
|
||||||
A DM is the widest seat in the room precisely because it's the most private one — the bot answers you there with everything *you* could see, stitched together. A public channel is the opposite: the whole org can read it, so it only ever draws on what the whole org is allowed to know.
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
If you're not in a private channel, its memory doesn't exist for you — not even by inference in a DM. The bot only ever reads with the asker's own access, so it can't surface something you couldn't otherwise see.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
**Example:** you DM the bot asking "what did we decide about the Acme deal?" It can draw on the public `#sales` channel, the private `#acme-deal` channel if you're in it, and anything it's learned about you directly — and it'll cite which one the answer came from. Ask the same question in `#general`, a public channel, and it can only answer from what `#general` and other public channels already know — the private `#acme-deal` context simply isn't in scope there.
|
|
||||||
|
|
||||||
## Tool access follows you, not the connection
|
|
||||||
|
|
||||||
Tools like GitHub and Linear can be connected two ways — **Organization (shared)**, set up once by an admin as a fallback the whole team can read from, or **Personal (yours)**, your own connection for your own reads and actions. Both show up on the same connections page; it's one tool catalog, connected at two possible scopes.
|
|
||||||
|
|
||||||
Whichever scope answered, the result is still bounded by what *you* could already see or do in that tool yourself — Company Brain never gets a standing key to "everything Linear knows." If you're not on a private Linear team, the bot can't surface those issues to you either, even through the org-shared connection.
|
|
||||||
|
|
||||||
| | Reads | Writes |
|
|
||||||
|---|---|---|
|
|
||||||
| **Behavior** | Try your personal connection first, then fall back to org-shared | Always run under your own connection |
|
|
||||||
| **Why** | Gives you the fullest access you're entitled to | Attributes the action to a real person, never a shared service account |
|
|
||||||
|
|
||||||
Admins can also act through the org-shared connection directly, for the cases where that's the point.
|
|
||||||
|
|
||||||
## Leasing: borrowing access for one request
|
|
||||||
|
|
||||||
Sometimes a request needs a tool neither you nor the org has connected — but a teammate has it connected personally. Rather than failing, Company Brain can ask that teammate directly: it posts a card in Slack asking them to approve or deny lending access for that one request.
|
|
||||||
|
|
||||||
- Nothing is granted silently — a real person has to accept the card.
|
|
||||||
- Access is short-lived and scoped to the single request that triggered it, not standing access to your account.
|
|
||||||
- The teammate can say no, and the request simply doesn't go through.
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
Leasing is a fallback of last resort — it only comes up when nobody's connected the tool at the org level yet. See [Connectors](/company-brain/connectors) to close that gap for good.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## API keys inherit the same graph
|
|
||||||
|
|
||||||
A scoped or agent API key can only reach what its owner could already reach by asking directly. A member can't mint a key that reads another member's employee memory or a private channel they're not in — the graph above applies identically whether a person is asking or a key is.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Connectors" icon="plug" href="/company-brain/connectors">
|
|
||||||
Set up the data and tool connections this page describes.
|
|
||||||
</Card>
|
|
||||||
<Card title="Automations & proactiveness" icon="wand-magic-sparkles" href="/company-brain/automations">
|
|
||||||
How scheduled runs and unprompted replies respect the same graph.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
---
|
|
||||||
title: "Setup and Onboarding"
|
|
||||||
sidebarTitle: "Setup"
|
|
||||||
description: "Creating a team workspace and installing it into Slack"
|
|
||||||
icon: "rocket"
|
|
||||||
---
|
|
||||||
|
|
||||||
Setting up Company Brain is two admin steps: create the workspace, then install it into Slack. Everyone else joins on their own after that — see [Greeting new teammates](/company-brain/use-cases/greeting).
|
|
||||||
|
|
||||||
## 1. Create your team workspace
|
|
||||||
|
|
||||||
Creating a workspace sets up your shared **Team Brain** and your private **My Brain** in one step.
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Sign up">
|
|
||||||
Head to [app.supermemory.ai](https://app.supermemory.ai) and create an account.
|
|
||||||
</Step>
|
|
||||||
<Step title="Choose Team">
|
|
||||||
On the **About** step, switch from **Personal** to **Team**.
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
Team workspaces are invite-only during the private beta. Not invited yet? Email **support@supermemory.com**, or start Personal and invite your team once you're in.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||

|
|
||||||
</Step>
|
|
||||||
<Step title="Add your company domain and confirm">
|
|
||||||
Enter your domain (for example `acme.com`) and confirm. Supermemory researches the company from there and seeds a starting profile, before any source finishes syncing.
|
|
||||||
|
|
||||||

|
|
||||||
</Step>
|
|
||||||
<Step title="Add to Slack, connect apps, and invite your team">
|
|
||||||
All three run in parallel with research, and none of them block it:
|
|
||||||
|
|
||||||
- **Add to Slack** — kicks off the install flow below.
|
|
||||||
- **Connect apps** — Linear, Granola, Sentry, and more.
|
|
||||||
- **Invite teammates** — now, not later. No per-seat pricing, so invite everyone in your Slack.
|
|
||||||
|
|
||||||

|
|
||||||
</Step>
|
|
||||||
<Step title="You're ready">
|
|
||||||
Supermemory's already learned a real amount about your company by the time research finishes. Watch Slack for a DM from it walking you through what it can do.
|
|
||||||
|
|
||||||

|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
**Try it:** ask `What does {your company} do?` — you should get a real answer from the seeded profile.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## 2. Install into Slack (admin)
|
|
||||||
|
|
||||||
<AccordionGroup>
|
|
||||||
<Accordion title="Don't have a Slack workspace yet?">
|
|
||||||
Go to [app.slack.com](https://app.slack.com) to create one first — Company Brain installs into an existing workspace, it doesn't create one for you.
|
|
||||||
|
|
||||||

|
|
||||||
</Accordion>
|
|
||||||
</AccordionGroup>
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Confirm company and domain">
|
|
||||||
Click **Install to Slack**. Not an admin? This triggers Slack's own request-to-install flow instead.
|
|
||||||
</Step>
|
|
||||||
<Step title="Hand off to Slack">
|
|
||||||
The web app hands off immediately — "we've DM'd you in Slack."
|
|
||||||
</Step>
|
|
||||||
<Step title="Home channel">
|
|
||||||
The agent creates `#company-brain`, posts an intro there, and announces itself once in `#general`.
|
|
||||||
</Step>
|
|
||||||
<Step title="Approve channels to read">
|
|
||||||
You approve each channel with a tap — it never joins one silently.
|
|
||||||
</Step>
|
|
||||||
<Step title="Add your team">
|
|
||||||
Invite people with a picker, a workspace-wide toggle, or email.
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Greeting new teammates" icon="user-plus" href="/company-brain/use-cases/greeting">
|
|
||||||
What member join looks like from a new hire's side.
|
|
||||||
</Card>
|
|
||||||
<Card title="The permissions graph" icon="shield-check" href="/company-brain/permissions">
|
|
||||||
What each person can see once they're in.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
---
|
|
||||||
title: "Acting in Tools"
|
|
||||||
sidebarTitle: "Acting in Tools"
|
|
||||||
description: "List open PRs, create Linear issues — actions run under your own account"
|
|
||||||
icon: "wrench"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention, AgentLink } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
You don't just want to *know* something — you want Company Brain to do the next step in GitHub or Linear for you.
|
|
||||||
|
|
||||||
## What happens
|
|
||||||
|
|
||||||
### Read from a tool
|
|
||||||
|
|
||||||
<SlackThread channel="#eng">
|
|
||||||
<SlackMessage self hasAvatar time="3:22 PM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> what are my open PRs in the web repo?
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="3:22 PM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
You have **3 open PRs** on `supermemory/web`:
|
|
||||||
- `#1199` staff custom MCP connection cards — review requested
|
|
||||||
- `#1197` company brain onboarding research UI — changes requested
|
|
||||||
- `#1181` brain connections board — approved, not merged
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
### Write to a tool
|
|
||||||
|
|
||||||
<SlackThread channel="#eng">
|
|
||||||
<SlackMessage self hasAvatar time="3:24 PM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> create a Linear issue: fix the onboarding redirect
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="3:24 PM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Created **ENG-842** — *Fix the onboarding redirect* in Engineering, assigned to you. <AgentLink href="https://linear.app">Open in Linear</AgentLink>
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
Both turns use [tool connectors](/company-brain/connectors) (GitHub, Linear). Reads try your **personal** connection first and fall back to the org-shared one. **Writes always run under your own account** — so the Linear issue is attributed to you, never silently as "the org."
|
|
||||||
|
|
||||||
If you haven't connected the tool and neither has the org, Company Brain can ask a teammate to [lease](/company-brain/permissions#leasing-borrowing-access-for-one-request) temporary access for that one request.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Permissions" icon="shield-check" href="/company-brain/permissions">
|
|
||||||
Personal vs org tools, and how leasing works.
|
|
||||||
</Card>
|
|
||||||
<Card title="Connectors" icon="plug" href="/company-brain/connectors">
|
|
||||||
Connect GitHub, Linear, and the rest.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
---
|
|
||||||
title: "Greeting New Teammates"
|
|
||||||
sidebarTitle: "Greeting Teammates"
|
|
||||||
description: "Connect card, welcome DM, and first answer — activation on day one"
|
|
||||||
icon: "user-plus"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
A new hire joins the Slack workspace. They shouldn't need a web signup form or a long handbook read before Company Brain is useful — the whole first experience happens in Slack.
|
|
||||||
|
|
||||||
## What happens
|
|
||||||
|
|
||||||
They get a connect card, tap **Connect me**, and receive a welcome DM:
|
|
||||||
|
|
||||||
<SlackThread type="dm" dmWith={{ name: "supermemory" }} hasAvatar>
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
<SlackMessage bot hasAvatar time="9:02 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Welcome to **Acme**. Here's what I know, what I can access, and what I keep private.
|
|
||||||
|
|
||||||
Try one of these:
|
|
||||||
1. What does Acme do?
|
|
||||||
2. Who owns onboarding?
|
|
||||||
3. Where do we track bugs?
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage name="Sam" color="#36C5F0" time="9:03 AM">
|
|
||||||
What does Acme do?
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="9:03 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Acme builds memory infrastructure for AI apps — shared context for teams and agents. *(from the company profile your admin seeded at setup)*
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
Right after the first answer, they're prompted to connect personal tools (Linear, Notion) so day-two questions can hit live data.
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
This is the [member join flow](/company-brain/setup) told as a scenario. Tapping **Connect me** creates a passwordless account from their Slack email, provisions their private [employee memory](/company-brain/permissions), and consumes a seat. The three starter questions are seeded so the first useful answer happens on the first tap — that's the activation moment.
|
|
||||||
|
|
||||||
No web app required for the new hire. Admins still do the one-time workspace + Slack install on the web.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Setup and onboarding" icon="rocket" href="/company-brain/setup">
|
|
||||||
The full admin + member flow.
|
|
||||||
</Card>
|
|
||||||
<Card title="Permissions" icon="shield-check" href="/company-brain/permissions">
|
|
||||||
Employee memory vs. public and private channel memory.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
---
|
|
||||||
title: "Incidents & Downtime Chatter"
|
|
||||||
sidebarTitle: "Incidents"
|
|
||||||
description: "Answer 'is prod down?' from live Sentry, and post scheduled error digests"
|
|
||||||
icon: "triangle-alert"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, AgentLink } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
Someone drops a vague "is prod down?" in `#eng`. Company Brain correlates it with what's actually failing right now — and a separate automation keeps a daily error digest flowing without anyone asking.
|
|
||||||
|
|
||||||
## What happens
|
|
||||||
|
|
||||||
<SlackThread channel="#eng" members={48}>
|
|
||||||
<SlackMessage name="Alex" color="#E01E5A" time="11:03 AM">
|
|
||||||
is prod down? a couple of customers are pinging me
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="11:03 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Not a full outage — **`api/search` is elevated** (42 errors in the last 15m, mostly `timeout on vectorize`). Sentry issue <AgentLink href="https://example.com">SM-2041</AgentLink>. Deploy from this morning is the likely culprit; rollback candidate is `af61880`.
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
Or, without anyone asking, a scheduled automation posts every morning:
|
|
||||||
|
|
||||||
<SlackThread channel="#eng">
|
|
||||||
<SlackMessage bot hasAvatar time="9:00 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
**Daily error digest** — 3 issues new since yesterday, 1 still spiking (`api/search` timeout). Full list in Sentry.
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
The first exchange is a [chime-in](/company-brain/automations) (or an @mention — either works) that hits the **Sentry** [tool connector](/company-brain/connectors) for live error data. The second is an [automation](/company-brain/automations): a recurring prompt on a cron that posts to a channel using org-shared connections only, so it never runs as a specific person.
|
|
||||||
|
|
||||||
If the channel is private, only an admin can target it with an automation, and the bot fails closed if it can't verify visibility.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Connectors" icon="plug" href="/company-brain/connectors">
|
|
||||||
Connect Sentry (and friends) for live incident context.
|
|
||||||
</Card>
|
|
||||||
<Card title="Automations" icon="wand-magic-sparkles" href="/company-brain/automations">
|
|
||||||
Schedule digests and understand chime-in.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
---
|
|
||||||
title: "Answering from Your Docs"
|
|
||||||
sidebarTitle: "From Docs"
|
|
||||||
description: "Ask about roadmaps, specs, and handbooks in plain language"
|
|
||||||
icon: "book"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
The Q2 roadmap lives in Notion. Nobody wants to dig for the link mid-conversation — they just ask.
|
|
||||||
|
|
||||||
## What happens
|
|
||||||
|
|
||||||
<SlackThread channel="#product">
|
|
||||||
<SlackMessage self hasAvatar time="10:18 AM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> what's in our Q2 roadmap?
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="10:18 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Three themes for Q2:
|
|
||||||
1. **Company Brain GA** — Slack bot, connectors, automations
|
|
||||||
2. **Memory Router v2** — lower latency, better multi-tenant isolation
|
|
||||||
3. **Enterprise SSO + audit log**
|
|
||||||
|
|
||||||
Full doc: *Q2 Roadmap* in Notion (last updated Jun 3).
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
This is a [data connector](/company-brain/connectors) (Notion, Google Drive, or OneDrive) feeding [public channel memory](/company-brain/permissions). Content is fetched, chunked, embedded, and re-synced on a schedule — so when the roadmap changes in Notion, answers stay current without anyone re-uploading.
|
|
||||||
|
|
||||||
Same pattern works for handbooks, design docs, RFCs, and "where do we document X?" style questions. If it doesn't know, it says so rather than guessing.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Connectors" icon="plug" href="/company-brain/connectors">
|
|
||||||
Bring Drive, Notion, and OneDrive into public channel memory.
|
|
||||||
</Card>
|
|
||||||
<Card title="Permissions" icon="shield-check" href="/company-brain/permissions">
|
|
||||||
Who can see what once it's indexed.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
---
|
|
||||||
title: "Long-Horizon Research"
|
|
||||||
sidebarTitle: "Long-Horizon Research"
|
|
||||||
description: "Questions that need many sources synthesized into one answer"
|
|
||||||
icon: "telescope"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
Some questions aren't a quick lookup. They need pulling information from a lot of distinct places — docs, tickets, meetings, code, past Slack threads — and synthesizing it into one coherent answer. Closer to a research project than "what's the pricing?"
|
|
||||||
|
|
||||||
## What it will look like
|
|
||||||
|
|
||||||
<SlackThread channel="#product">
|
|
||||||
<SlackMessage self hasAvatar time="11:10 AM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> prepare a brief on how we've handled enterprise SSO requests over the last two quarters — who asked, what we promised, what's still open, and what engineering thinks the real effort is
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="11:14 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
**Enterprise SSO — two-quarter brief**
|
|
||||||
|
|
||||||
- **Demand:** 6 serious asks (Acme, Globex, Initech, …). Three converted to paid pilots.
|
|
||||||
- **Promises made:** Okta + Azure AD for pilots; custom SAML called "roadmap" in two decks.
|
|
||||||
- **Still open:** Globex blocked on SCIM; Initech waiting on audit-log export.
|
|
||||||
- **Eng view:** ~6 weeks for Okta/Azure path (from `#eng` thread Mar 18 + Linear ENG-610); SCIM is the long pole.
|
|
||||||
|
|
||||||
Sources: Q1/Q2 enterprise notes, Linear ENG-610, Granola "SSO planning", `#sales` threads. Want this as a Linear doc?
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
Today, a single turn can already hit multiple [connectors](/company-brain/connectors) and channel memory in one answer. Long-horizon research is for the cases where that isn't enough — the work needs more time, more parallel digging, and a structured synthesis step before it replies.
|
|
||||||
|
|
||||||
We're deliberately not documenting an internal "spawn N children" mechanism here. What matters for you: ask a research-shaped question, get a sourced brief back, without you having to run six separate searches.
|
|
||||||
|
|
||||||
Until it ships, break big questions into smaller ones (docs, then tickets, then "summarize what we have") — Company Brain already handles each of those well.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="From docs" icon="book" href="/company-brain/use-cases/knowledge-recall">
|
|
||||||
What single-turn knowledge recall looks like today.
|
|
||||||
</Card>
|
|
||||||
<Card title="Meeting recall" icon="calendar-check" href="/company-brain/use-cases/meeting-recall">
|
|
||||||
Pulling decisions out of synced notes.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
---
|
|
||||||
title: "Meeting Recall"
|
|
||||||
sidebarTitle: "Meeting Recall"
|
|
||||||
description: "Ask what was decided in a meeting and get the answer from synced notes"
|
|
||||||
icon: "calendar-check"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
Someone missed the Acme call — or was there and forgot the outcome. They ask in Slack and get the decision, with a path back to the source notes.
|
|
||||||
|
|
||||||
## What happens
|
|
||||||
|
|
||||||
<SlackThread channel="#sales">
|
|
||||||
<SlackMessage self hasAvatar time="4:05 PM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> what did we decide in the meeting with Acme?
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="4:05 PM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Acme is moving to annual billing at the current rate, with a 90-day pilot on the enterprise SSO add-on. Follow-up owned by **Priya**, due Friday. *(from Granola notes, Acme QBR — Jun 11)*
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
Meeting notes come in through a [connector](/company-brain/connectors) — Granola for live meeting notes, or Drive/Notion if notes land there. Once synced, they live in [public channel memory](/company-brain/permissions), so anyone on the team can ask about them. The answer cites the source so you can open the original notes and verify.
|
|
||||||
|
|
||||||
Ask about a decision, an owner, a date, or "what did we promise them" — plain language is fine.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Permissions" icon="shield-check" href="/company-brain/permissions">
|
|
||||||
Why meeting notes land in public channel memory.
|
|
||||||
</Card>
|
|
||||||
<Card title="Connectors" icon="plug" href="/company-brain/connectors">
|
|
||||||
Connect Granola, Drive, or Notion.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
---
|
|
||||||
title: "Meeting Scheduling"
|
|
||||||
sidebarTitle: "Scheduling"
|
|
||||||
description: "Find free time and send a calendar invite from Slack"
|
|
||||||
icon: "calendar-plus"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
**Coming soon.** Calendar scheduling isn't in Company Brain yet — there's no calendar connector in the catalog today. This page shows the experience we're building toward, using a real exchange from our own team.
|
|
||||||
</Note>
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
A teammate needs help and doesn't want to play calendar ping-pong. They ask in Slack; Company Brain checks availability and offers to book the slot.
|
|
||||||
|
|
||||||
## What it will look like
|
|
||||||
|
|
||||||
<SlackThread channel="#eng">
|
|
||||||
<SlackMessage name="Sam" color="#36C5F0" time="1:12 PM">
|
|
||||||
Dhravya are you free this afternoon? need some help with dev setup
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="1:12 PM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Dhravya is free at **2:00 PM**. Sending an invite to block **20 mins** on both your calendars.
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
When this ships, it will be a [tool connector](/company-brain/connectors) against the calendar — same personal-vs-org rules and [write-under-your-account](/company-brain/permissions) attribution as Linear or GitHub. Creating an invite is a write, so it runs as the person who has the calendar connected (or via an explicit [lease](/company-brain/permissions#leasing-borrowing-access-for-one-request) if someone else is lending access for that one request).
|
|
||||||
|
|
||||||
Until then: ask Company Brain for *context* around scheduling ("who's the right person for dev setup?" / "when did we last pair on this?") and book the time the usual way.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Permissions" icon="shield-check" href="/company-brain/permissions">
|
|
||||||
How personal tools and leasing will apply to calendar.
|
|
||||||
</Card>
|
|
||||||
<Card title="What you can do" icon="sparkles" href="/company-brain/use-cases/overview">
|
|
||||||
Back to all scenarios — including what's shipped today.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
---
|
|
||||||
title: "What You Can Do"
|
|
||||||
sidebarTitle: "Overview"
|
|
||||||
description: "Real scenarios for Company Brain — from Slack answers to sandbox debugging"
|
|
||||||
icon: "sparkles"
|
|
||||||
---
|
|
||||||
|
|
||||||
Company Brain is most useful when it shows up in the work you already do. These walkthroughs are short, concrete scenarios — each one is a real exchange, what the bot is actually doing under the hood, and which concept page to read if you want the full picture.
|
|
||||||
|
|
||||||
## Shipped today
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Automatic support" icon="headset" href="/company-brain/use-cases/support">
|
|
||||||
Customer question in Slack; Company Brain chimes in with the answer.
|
|
||||||
</Card>
|
|
||||||
<Card title="Incidents & downtime" icon="triangle-alert" href="/company-brain/use-cases/incidents">
|
|
||||||
"Is prod down?" answered from live Sentry, plus scheduled digests.
|
|
||||||
</Card>
|
|
||||||
<Card title="Meeting recall" icon="calendar-check" href="/company-brain/use-cases/meeting-recall">
|
|
||||||
"What did we decide with Acme?" from synced meeting notes.
|
|
||||||
</Card>
|
|
||||||
<Card title="Answering from docs" icon="book" href="/company-brain/use-cases/knowledge-recall">
|
|
||||||
Roadmaps, specs, and handbooks — asked in plain language.
|
|
||||||
</Card>
|
|
||||||
<Card title="Acting in tools" icon="wrench" href="/company-brain/use-cases/acting-in-tools">
|
|
||||||
List open PRs, create a Linear issue — under your own account.
|
|
||||||
</Card>
|
|
||||||
<Card title="Greeting new teammates" icon="user-plus" href="/company-brain/use-cases/greeting">
|
|
||||||
Connect card, welcome DM, first answer — activation on day one.
|
|
||||||
</Card>
|
|
||||||
<Card title="Sandbox debugging" icon="terminal" href="/company-brain/use-cases/sandbox-debugging">
|
|
||||||
Actually run the failing test in an isolated workspace.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
||||||
## Coming soon
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Long-horizon research" icon="telescope" href="/company-brain/use-cases/long-horizon-research">
|
|
||||||
Questions that need many sources synthesized into one answer.
|
|
||||||
</Card>
|
|
||||||
<Card title="Meeting scheduling" icon="calendar-plus" href="/company-brain/use-cases/meeting-scheduling">
|
|
||||||
Find free time and send a calendar invite from Slack.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
||||||
If you're still setting things up, start with [What is Company Brain?](/company-brain/overview) and work through the Concepts section first — the walkthroughs assume those ideas.
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
---
|
|
||||||
title: "Sandbox Debugging"
|
|
||||||
sidebarTitle: "Sandbox"
|
|
||||||
description: "Hand it a failing test — it runs the code in an isolated workspace and reports back"
|
|
||||||
icon: "terminal"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
A test is failing and the stack trace alone isn't enough. You want Company Brain to actually run the code, not just describe what it *would* do.
|
|
||||||
|
|
||||||
## What happens
|
|
||||||
|
|
||||||
<SlackThread channel="#eng">
|
|
||||||
<SlackMessage self hasAvatar time="5:41 PM">
|
|
||||||
<img src="/images/company-brain/dhravya-slack-icon.jpg" alt="" />
|
|
||||||
<Mention>supermemory</Mention> the `permissions.lease` unit test is failing on main — can you reproduce and tell me why?
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="5:42 PM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Reproduced in a sandbox against `main`.
|
|
||||||
|
|
||||||
**Failure:** `permissions.lease` expects temporary access to expire in 15 minutes, but the fixture still asserts 30.
|
|
||||||
**Cause:** the default was shortened in `#1188`; the unit test wasn't updated.
|
|
||||||
**Fix:** point the assertion at the shared default so it can't drift again.
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
Company Brain spins up an **isolated workspace**, checks out what it needs, runs the command, and reads the output. It's not guessing from static code alone — it's executing.
|
|
||||||
|
|
||||||
Guardrails are intentional, so it's safe to hand real tasks to:
|
|
||||||
|
|
||||||
- No `git push`, no deploys
|
|
||||||
- No elevated privileges (`sudo` and similar are blocked)
|
|
||||||
- No reaching arbitrary internal network addresses
|
|
||||||
- No long-running dev servers
|
|
||||||
|
|
||||||
Think of it as a sealed workbench: useful for reproduce / inspect / explain loops, not for shipping changes on your behalf.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Acting in tools" icon="wrench" href="/company-brain/use-cases/acting-in-tools">
|
|
||||||
When the next step is a Linear issue or a PR lookup.
|
|
||||||
</Card>
|
|
||||||
<Card title="What you can do" icon="sparkles" href="/company-brain/use-cases/overview">
|
|
||||||
All the scenario walkthroughs.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
---
|
|
||||||
title: "From Support Ticket to Code Fix"
|
|
||||||
sidebarTitle: "Escalation"
|
|
||||||
description: "A Plain ticket gets triaged in Slack, and an @mention hands the fix to Cursor"
|
|
||||||
icon: "bug"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage, Mention, FileAttachment, AgentLink, SlackUnfurl, SlackButton } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
A customer files a ticket through Plain. It lands in `#support`, gets triaged with context Company Brain already has lying around, and — instead of someone manually filing a bug and waiting — an @mention hands the whole thing straight to Cursor.
|
|
||||||
|
|
||||||
## What happens
|
|
||||||
|
|
||||||
<SlackThread channel="#support" members={24}>
|
|
||||||
<SlackMessage
|
|
||||||
name="Plain"
|
|
||||||
badges={["APP"]}
|
|
||||||
hasAvatar
|
|
||||||
time="10:12 AM"
|
|
||||||
>
|
|
||||||
<img src="/images/company-brain/plain-icon.png" alt="" />
|
|
||||||
New conversation: <AgentLink href="#">rewriteQuery param not working</AgentLink>
|
|
||||||
<br />
|
|
||||||
**Jordan Alvarez** (acme-corp.io) sent a **new message**.
|
|
||||||
<SlackUnfurl footer="Added by Plain">
|
|
||||||
hi team, just tried the `rewriteQuery` param on the v3 search endpoint and it doesn't seem to actually do anything — tried a few different values, results look identical either way. can someone take a look
|
|
||||||
</SlackUnfurl>
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="10:13 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Confirmed, this is a real one — a couple of people have also flagged it on GitHub over the last week.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Quick context: v3 search is deprecated, but we've committed to legacy support through end of year, so it's still worth fixing rather than telling people to migrate. Most likely cause is a change <Mention>Adam</Mention> shipped last week to cut down query-rewrite costs — looks like it short-circuits before `rewriteQuery` gets applied in some cases.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<Mention>cursor</Mention> can you take this one? Full context attached.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<FileAttachment name="Context.md" />
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage
|
|
||||||
name="Cursor"
|
|
||||||
badges={["AGENT"]}
|
|
||||||
hasAvatar
|
|
||||||
time="10:14 AM"
|
|
||||||
>
|
|
||||||
<img src="/images/company-brain/cursor-icon.png" alt="" />
|
|
||||||
<AgentLink href="#">Agent thread started</AgentLink>
|
|
||||||
<br />
|
|
||||||
Reproducing against the v3 search test suite now.
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage
|
|
||||||
name="Cursor"
|
|
||||||
badges={["AGENT"]}
|
|
||||||
hasAvatar
|
|
||||||
time="10:19 AM"
|
|
||||||
>
|
|
||||||
<img src="/images/company-brain/cursor-icon.png" alt="" />
|
|
||||||
Fixed — `rewriteQuery` was getting skipped by the new cost short-circuit whenever a query was already cached. Pushed on <AgentLink href="#">#2312</AgentLink>.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
**Resolution:**
|
|
||||||
<br />
|
|
||||||
• Scoped the short-circuit to skip only the rewrite step, not the whole `rewriteQuery` path
|
|
||||||
<br />
|
|
||||||
• Added a regression test covering `rewriteQuery` against a cache hit
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
**Repository:** `supermemoryai/mono`
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<SlackButton variant="primary">Open in Web</SlackButton>
|
|
||||||
<SlackButton>Open in Desktop</SlackButton>
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="10:20 AM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
<Mention self>Dhravya</Mention> I'll let you review that and let the customer know we have a fix ready.
|
|
||||||
<br />
|
|
||||||
Please do it ASAP — it's an enterprise customer!
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
The first two turns are the same pattern as [Automatic Support](/company-brain/use-cases/support): the bot is already a member of `#support`, so it [chimes in](/company-brain/automations) unprompted, correlating the ticket against known GitHub issues and whatever it knows about the codebase and the v3 deprecation timeline.
|
|
||||||
|
|
||||||
The handoff to Cursor is different. That's not a chime-in — it's an explicit `@mention`, and Cursor is wired in as a [tool connector](/company-brain/connectors) (a custom MCP server, same as GitHub or Linear under the hood) that can act, not just answer. Naming it by name is what triggers the write: Company Brain hands off the attached context and Cursor opens its own agent thread against the repo, the same way a mention of GitHub or Linear in [Acting in Tools](/company-brain/use-cases/acting-in-tools) triggers a write rather than a read. Nothing happens in the codebase without that explicit ask.
|
|
||||||
|
|
||||||
Whether that handoff is even possible follows the same [permissions](/company-brain/permissions) rules as any other tool: it runs under whichever connection — personal or org-shared — is actually wired up for Cursor, and it's scoped to what that connection can see.
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Acting in Tools" icon="wrench" href="/company-brain/use-cases/acting-in-tools">
|
|
||||||
How @mentions trigger writes instead of reads.
|
|
||||||
</Card>
|
|
||||||
<Card title="Connectors" icon="plug" href="/company-brain/connectors">
|
|
||||||
Wire up Plain, GitHub, and custom MCP servers like Cursor.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
---
|
|
||||||
title: "Automatic Support"
|
|
||||||
sidebarTitle: "Support"
|
|
||||||
description: "Company Brain chimes in on customer questions with answers from docs and tickets"
|
|
||||||
icon: "headset"
|
|
||||||
---
|
|
||||||
|
|
||||||
import { SlackThread, SlackMessage } from "/snippets/slack-message.mdx";
|
|
||||||
|
|
||||||
## The scenario
|
|
||||||
|
|
||||||
A customer question lands in `#support`. Nobody has to @mention the bot — it already has the answer from past tickets and the help docs.
|
|
||||||
|
|
||||||
## What happens
|
|
||||||
|
|
||||||
<SlackThread channel="#support" members={24}>
|
|
||||||
<SlackMessage name="Maya" color="#2BAC76" time="2:14 PM">
|
|
||||||
customer on the Pro plan is asking if they can export their full memory graph as CSV — do we support that?
|
|
||||||
</SlackMessage>
|
|
||||||
<SlackMessage bot hasAvatar time="2:14 PM">
|
|
||||||
<img src="/images/company-brain/supermemory-slack-icon.png" alt="" />
|
|
||||||
Yes — **Settings → Export → Full graph (CSV)**. Available on Pro and above. Same answer went out on ticket PLN-1842 last week if you want the exact wording.
|
|
||||||
</SlackMessage>
|
|
||||||
</SlackThread>
|
|
||||||
|
|
||||||
## What's really going on
|
|
||||||
|
|
||||||
This is [proactiveness (chime-in)](/company-brain/automations) plus a connected support tool (Plain) and public channel memory. The bot is already a member of `#support` (an admin invited it — it never joins on its own). It decided the answer was clear enough to speak without being asked, pulled the export path from docs in public channel memory, and cited a recent ticket from Plain.
|
|
||||||
|
|
||||||
Same channel scope rules apply: a public support channel writes durable learnings back to public channel memory; a private support channel keeps them scoped to that room's own memory. See [Permissions](/company-brain/permissions).
|
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
|
||||||
<Card title="Automations & proactiveness" icon="wand-magic-sparkles" href="/company-brain/automations">
|
|
||||||
How chime-in decides when to speak.
|
|
||||||
</Card>
|
|
||||||
<Card title="Connectors" icon="plug" href="/company-brain/connectors">
|
|
||||||
Wire up Plain and your help docs.
|
|
||||||
</Card>
|
|
||||||
</CardGroup>
|
|
||||||
|
|
@ -56,7 +56,7 @@ Uploading a long PDF does more than store bytes: Supermemory derives many memori
|
||||||
## Properties and rules of memories
|
## Properties and rules of memories
|
||||||
|
|
||||||
1. Memories are atomic - Each memory has enough information and context about one particular topic
|
1. Memories are atomic - Each memory has enough information and context about one particular topic
|
||||||
2. They always build on top of each other - with `updates`, the model knows the history, a memory `extends` from other memories, and new facts are derived (`derives` relation) from existing knowledg.e
|
2. They always build on top of each other - with `updates`, the model knows the history, a memory `extends` from other memories, and new facts are derived (`derives` relation) from existing knowledge.
|
||||||
|
|
||||||
## Memory relationships
|
## Memory relationships
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -388,6 +388,8 @@
|
||||||
"pages": [
|
"pages": [
|
||||||
"integrations/openclaw",
|
"integrations/openclaw",
|
||||||
"integrations/claude-code",
|
"integrations/claude-code",
|
||||||
|
"integrations/cursor",
|
||||||
|
"integrations/grok-bot",
|
||||||
"integrations/opencode",
|
"integrations/opencode",
|
||||||
"integrations/codex",
|
"integrations/codex",
|
||||||
"integrations/hermes"
|
"integrations/hermes"
|
||||||
|
|
@ -397,38 +399,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tab": "Plugins and MCP"
|
"tab": "Plugins and MCP"
|
||||||
},
|
|
||||||
{
|
|
||||||
"tab": "Company Brain",
|
|
||||||
"groups": [
|
|
||||||
{
|
|
||||||
"group": "Concepts",
|
|
||||||
"pages": [
|
|
||||||
"company-brain/overview",
|
|
||||||
"company-brain/setup",
|
|
||||||
"company-brain/permissions",
|
|
||||||
"company-brain/connectors",
|
|
||||||
"company-brain/automations",
|
|
||||||
"company-brain/outside-slack"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "What you can do",
|
|
||||||
"pages": [
|
|
||||||
"company-brain/use-cases/overview",
|
|
||||||
"company-brain/use-cases/support",
|
|
||||||
"company-brain/use-cases/incidents",
|
|
||||||
"company-brain/use-cases/support-escalation",
|
|
||||||
"company-brain/use-cases/meeting-recall",
|
|
||||||
"company-brain/use-cases/knowledge-recall",
|
|
||||||
"company-brain/use-cases/acting-in-tools",
|
|
||||||
"company-brain/use-cases/greeting",
|
|
||||||
"company-brain/use-cases/sandbox-debugging",
|
|
||||||
"company-brain/use-cases/long-horizon-research",
|
|
||||||
"company-brain/use-cases/meeting-scheduling"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -957,6 +927,11 @@
|
||||||
"destination": "/overview/analytics",
|
"destination": "/overview/analytics",
|
||||||
"permanent": true,
|
"permanent": true,
|
||||||
"source": "/analytics"
|
"source": "/analytics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"destination": "/overview/what-is-supermemory",
|
||||||
|
"permanent": true,
|
||||||
|
"source": "/company-brain/:slug*"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styling": {
|
"styling": {
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 680 KiB |