Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/obsidian-connector/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
main.js
*.js.map
dist/
26 changes: 26 additions & 0 deletions apps/obsidian-connector/esbuild.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import esbuild from "esbuild"
import { readFileSync } from "fs"

Check notice on line 2 in apps/obsidian-connector/esbuild.config.mjs

View workflow job for this annotation

GitHub Actions / Quality Checks

lint/style/useNodejsImportProtocol

A Node.js builtin module should be imported with the node: protocol.

const manifest = JSON.parse(readFileSync("manifest.json", "utf8"))
const isProduction = process.argv[2] === "production"

const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: ["obsidian", "electron", "@codemirror/view", "@codemirror/state"],
format: "cjs",
target: "es2020",
outfile: "main.js",
sourcemap: isProduction ? false : "inline",
define: {
"process.env.PLUGIN_VERSION": JSON.stringify(manifest.version),
},
logLevel: "info",
})

if (isProduction) {
await context.rebuild()
await context.dispose()
} else {
await context.watch()
}
10 changes: 10 additions & 0 deletions apps/obsidian-connector/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"id": "supermemory",
"name": "Supermemory",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Sync your Obsidian vault to Supermemory for AI-powered search and memory extraction.",
"author": "Supermemory",
"authorUrl": "https://supermemory.ai",
"isDesktopOnly": false
}
18 changes: 18 additions & 0 deletions apps/obsidian-connector/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@repo/obsidian-plugin",
"version": "0.1.0",
"private": true,
"description": "Supermemory plugin for Obsidian",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production"
},
"dependencies": {
"obsidian": "^1.7.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.25.0",
"typescript": "^5.8.0"
}
}
148 changes: 148 additions & 0 deletions apps/obsidian-connector/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Notice, requestUrl } from "obsidian"
import type { SupermemorySettings } from "./types"

export interface PushResponse {
accepted: boolean
deletedCount: number
queuedCount: number
}

export interface ConnectionResponse {
id: string
}

export interface NotePayload {
path: string
content: string
title?: string
mtime?: number
frontmatter?: Record<string, unknown>
}

class SupermemoryAPIError extends Error {
constructor(
message: string,
public status?: number,
) {
super(message)
this.name = "SupermemoryAPIError"
}
}

class AuthenticationError extends Error {
constructor(message: string) {
super(message)
this.name = "AuthenticationError"
}
}

let _settings: SupermemorySettings | null = null

export function configure(settings: SupermemorySettings) {
_settings = settings
}

function getBaseUrl(): string {
if (!_settings) throw new Error("API not configured")
return _settings.apiBaseUrl.replace(/\/+$/, "")
}

function getApiKey(): string {
if (!_settings?.apiKey) throw new Error("API key not set")
return _settings.apiKey
}

async function makeAuthenticatedRequest<T>(
endpoint: string,
options: { method?: string; body?: unknown } = {},
): Promise<T> {
const apiKey = getApiKey()
const url = `${getBaseUrl()}${endpoint}`

try {
const response = await requestUrl({
url,
method: options.method ?? "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: options.body ? JSON.stringify(options.body) : undefined,
})

if (response.status === 401) {
throw new AuthenticationError(
"Invalid API key. Check your key in plugin settings.",
)
}

if (response.status === 429) {
throw new SupermemoryAPIError("Rate limited. Try again in a moment.", 429)
}

if (response.status >= 400) {
let errorMessage = `API request failed: ${response.status}`
try {
const errorBody = response.json as { error?: string }
if (errorBody.error) errorMessage = errorBody.error
} catch {}
throw new SupermemoryAPIError(errorMessage, response.status)
}

return response.json as T
} catch (err) {
if (err instanceof AuthenticationError) {
new Notice("Supermemory: invalid API key. Check plugin settings.")
throw err
}
if (err instanceof SupermemoryAPIError) {
if (err.status === 429) {
new Notice("Supermemory: rate limited. Try again in a moment.")
}
throw err
}
throw new SupermemoryAPIError(
`Network error: ${err instanceof Error ? err.message : "Unknown error"}`,
)
}
}

export async function createConnection(
vaultId: string,
vaultName: string,
containerTag: string,
): Promise<ConnectionResponse> {
return makeAuthenticatedRequest<ConnectionResponse>(
"/v3/connections/obsidian",
{
method: "POST",
body: { metadata: { vaultId, vaultName }, containerTag },
},
)
}

export async function pushNotes(
connectionId: string,
notes: NotePayload[],
): Promise<PushResponse> {
return makeAuthenticatedRequest<PushResponse>(
"/v3/connections/obsidian/push",
{
method: "POST",
body: { connectionId, notes },
},
)
}

export async function pushDeletions(
connectionId: string,
deletions: Array<{ path: string }>,
): Promise<PushResponse> {
return makeAuthenticatedRequest<PushResponse>(
"/v3/connections/obsidian/push",
{
method: "POST",
body: { connectionId, deletions },
},
)
}
173 changes: 173 additions & 0 deletions apps/obsidian-connector/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Notice, Plugin, type TAbstractFile, TFile } from "obsidian"

Check failure on line 1 in apps/obsidian-connector/src/main.ts

View workflow job for this annotation

GitHub Actions / Quality Checks

format

File content differs from formatting output
import { configure, createConnection } from "./api"
import { SupermemorySettingTab } from "./settings"
import { SyncEngine } from "./sync"
import type { SupermemorySettings } from "./types"

const DEFAULT_SETTINGS: SupermemorySettings = {
apiKey: "",
apiBaseUrl: "https://api.supermemory.ai",
containerTag: "sm_project_obsidian_default",
vaultName: "",
connectionId: "",
syncOnSave: true,
syncOnStartup: true,
syncMode: "all",
includedFolders: "",
}

function formatTimeAgo(ms: number): string {
const seconds = Math.floor((Date.now() - ms) / 1000)
if (seconds < 60) return "just now"
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
return `${hours}h ago`
}

export default class SupermemoryPlugin extends Plugin {
settings: SupermemorySettings = DEFAULT_SETTINGS
private syncEngine: SyncEngine | null = null
private statusBarEl: HTMLElement | null = null
private lastSyncTime: number | null = null
private lastSyncCount = 0

async onload() {
await this.loadSettings()
configure(this.settings)

this.statusBarEl = this.addStatusBarItem()
this.updateStatusBar()

this.addSettingTab(new SupermemorySettingTab(this.app, this))

this.addRibbonIcon("cloud-upload", "Sync vault to Supermemory", () => {
this.syncVault()
})

this.addCommand({
id: "sync-vault",
name: "Sync vault now",
callback: () => this.syncVault(),
})

if (this.settings.syncOnSave) {
this.registerVaultEvents()
}

if (this.settings.syncOnStartup && this.settings.apiKey) {
this.app.workspace.onLayoutReady(() => this.syncVault())
}
}

private updateStatusBar(syncing?: { current: number; total: number }) {
if (!this.statusBarEl) return

if (syncing) {
this.statusBarEl.setText(
`Supermemory: syncing ${syncing.current}/${syncing.total}...`,
)
return
}

if (this.lastSyncTime) {
this.statusBarEl.setText(
`Supermemory: synced ${formatTimeAgo(this.lastSyncTime)} · ${this.lastSyncCount} notes`,
)
} else if (!this.settings.apiKey) {
this.statusBarEl.setText("Supermemory: no API key")
} else {
this.statusBarEl.setText("Supermemory: ready")
}
}

private registerVaultEvents() {
this.registerEvent(
this.app.vault.on("modify", (file: TAbstractFile) => {
if (file instanceof TFile) this.syncEngine?.onFileChange(file)
}),
)
this.registerEvent(
this.app.vault.on("create", (file: TAbstractFile) => {
if (file instanceof TFile) this.syncEngine?.onFileChange(file)
}),
)
this.registerEvent(
this.app.vault.on("delete", (file: TAbstractFile) => {
if (file instanceof TFile) this.syncEngine?.onFileDelete(file)
}),
)
this.registerEvent(
this.app.vault.on("rename", (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) this.syncEngine?.onFileRename(file, oldPath)
}),
)
}

async ensureConnection(): Promise<string | null> {
if (!this.settings.apiKey) {
new Notice("Set your Supermemory API key in plugin settings first.")
return null
}

if (this.settings.connectionId) {
return this.settings.connectionId
}

const vaultId = (this.app as unknown as { appId: string }).appId
const vaultName = this.settings.vaultName || this.app.vault.getName()

try {
const result = await createConnection(
vaultId,
vaultName,
this.settings.containerTag,
)
this.settings.connectionId = result.id
await this.saveSettings()
return result.id
} catch (e) {
new Notice(
`Supermemory: failed to connect — ${e instanceof Error ? e.message : "unknown error"}`,
)
return null
}
}

private async syncVault() {
const connectionId = await this.ensureConnection()
if (!connectionId) return

if (!this.syncEngine) {
this.syncEngine = new SyncEngine(this.app, connectionId, this.settings)
} else {
this.syncEngine.updateSettings(this.settings)
}

const files = this.app.vault.getMarkdownFiles()
this.updateStatusBar({ current: 0, total: files.length })

const result = await this.syncEngine.fullSync((current, total) => {
this.updateStatusBar({ current, total })
})

this.lastSyncTime = Date.now()
this.lastSyncCount = result.queued
this.updateStatusBar()
}

async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData(),
)
}

async saveSettings() {
await this.saveData(this.settings)
configure(this.settings)
this.syncEngine?.updateSettings(this.settings)
this.updateStatusBar()
}
}
Loading
Loading