From 3d87d798d4382c09653ca01ad6afe4ed28a32780 Mon Sep 17 00:00:00 2001 From: Jim Park Date: Thu, 2 Apr 2026 23:41:53 -0700 Subject: [PATCH 1/4] feat: use git remote URL for cross-machine project memory sync Resolves #38 Problem: - Project memories use directory path hash as identifier - Different machines have different paths for the same project - Result: project memories don't sync across machines - Setting projectContainerTag affects ALL projects (memory pollution) Solution: - Use git remote origin URL as primary project identifier - Same git remote = same project tag across all machines - Fallback to directory hash if no git remote Benefits: - Transparent: works without user configuration - Automatic sync for same repository across machines - Automatic isolation for different repositories - Backward compatible: falls back to existing behavior --- src/services/tags.ts | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/services/tags.ts b/src/services/tags.ts index 964d111..b3dba7a 100644 --- a/src/services/tags.ts +++ b/src/services/tags.ts @@ -15,6 +15,24 @@ export function getGitEmail(): string | null { } } +/** + * Get the git remote URL for the given directory. + * This provides a stable, cross-machine identifier for projects. + * Returns null if not in a git repo or no remote configured. + */ +export function getGitRemoteUrl(directory: string): string | null { + try { + const remoteUrl = execSync("git config --get remote.origin.url", { + cwd: directory, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return remoteUrl || null; + } catch { + return null; + } +} + export function getUserTag(): string { // If userContainerTag is explicitly set, use it if (CONFIG.userContainerTag) { @@ -36,7 +54,14 @@ export function getProjectTag(directory: string): string { return CONFIG.projectContainerTag; } - // Otherwise, auto-generate based on containerTagPrefix + // Try to use git remote URL as a stable cross-machine project identifier + // This allows the same project on different machines to share memories + const remoteUrl = getGitRemoteUrl(directory); + if (remoteUrl) { + return `${CONFIG.containerTagPrefix}_project_${sha256(remoteUrl)}`; + } + + // Fall back to directory path hash (machine-specific) return `${CONFIG.containerTagPrefix}_project_${sha256(directory)}`; } @@ -45,4 +70,4 @@ export function getTags(directory: string): { user: string; project: string } { user: getUserTag(), project: getProjectTag(directory), }; -} +} \ No newline at end of file From c42b07ebfff30147620e17bcd59344da142bb166 Mon Sep 17 00:00:00 2001 From: Jim Park Date: Fri, 3 Apr 2026 01:19:50 -0700 Subject: [PATCH 2/4] fix: normalize git remote URLs for consistent cross-machine project tags SSH and HTTPS URLs for the same repo (e.g. git@github.com:user/repo.git vs https://github.com/user/repo) produced different hashes, silently breaking cross-machine memory sync. Normalize URLs to a canonical form before hashing. Also restores trailing newline. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/services/tags.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/services/tags.ts b/src/services/tags.ts index b3dba7a..ba9b760 100644 --- a/src/services/tags.ts +++ b/src/services/tags.ts @@ -15,6 +15,25 @@ export function getGitEmail(): string | null { } } +/** + * Normalize a git remote URL to a canonical form so that SSH, HTTPS, + * and with/without `.git` suffix all produce the same identifier. + * + * Examples: + * git@github.com:user/repo.git → github.com/user/repo + * https://github.com/user/repo → github.com/user/repo + * git@gitlab.com:org/sub/repo.git → gitlab.com/org/sub/repo + */ +export function normalizeGitUrl(url: string): string { + return url + .replace(/^[a-z+]+:\/\//, "") // strip protocol (https://, git://, ssh://) + .replace(/^[^@]+@/, "") // strip user@ prefix (git@, user@) + .replace(/:(\d+)\//, "/$1/") // preserve port numbers (e.g. :8080/) + .replace(":", "/") // SSH colon to slash (github.com:user → github.com/user) + .replace(/\.git$/, "") // strip trailing .git + .replace(/\/+$/, ""); // strip trailing slashes +} + /** * Get the git remote URL for the given directory. * This provides a stable, cross-machine identifier for projects. @@ -58,7 +77,7 @@ export function getProjectTag(directory: string): string { // This allows the same project on different machines to share memories const remoteUrl = getGitRemoteUrl(directory); if (remoteUrl) { - return `${CONFIG.containerTagPrefix}_project_${sha256(remoteUrl)}`; + return `${CONFIG.containerTagPrefix}_project_${sha256(normalizeGitUrl(remoteUrl))}`; } // Fall back to directory path hash (machine-specific) @@ -70,4 +89,4 @@ export function getTags(directory: string): { user: string; project: string } { user: getUserTag(), project: getProjectTag(directory), }; -} \ No newline at end of file +} From a88b0bd4e3acae5128cca752c0a3737757de1695 Mon Sep 17 00:00:00 2001 From: Jim Park Date: Sat, 4 Apr 2026 21:42:11 -0700 Subject: [PATCH 3/4] fix: query legacy directory-hash tag to preserve existing memories on upgrade Existing users have project memories stored under sha256(directory). After switching to sha256(normalizeGitUrl(remoteUrl)), those memories become silently inaccessible. Fix by querying both the new canonical tag and the legacy directory-based tag for all read operations (search, list, compaction context), deduplicating by memory ID. Writes go only to the new canonical tag so memories gradually migrate. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 67 +++++++++++++++++++++++++++++--------- src/services/compaction.ts | 16 ++++++--- src/services/tags.ts | 19 ++++++++++- 3 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index fecd624..8a8bf80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -127,15 +127,22 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { if (isFirstMessage) { injectedSessions.add(input.sessionID); - const [profileResult, userMemoriesResult, projectMemoriesListResult] = await Promise.all([ + const [profileResult, userMemoriesResult, projectMemoriesListResult, legacyProjectResult] = await Promise.all([ supermemoryClient.getProfile(tags.user, userMessage), supermemoryClient.searchMemories(userMessage, tags.user), supermemoryClient.listMemories(tags.project, CONFIG.maxProjectMemories), + tags.legacyProject + ? supermemoryClient.listMemories(tags.legacyProject, CONFIG.maxProjectMemories) + : Promise.resolve({ success: true, memories: [] } as const), ]); const profile = profileResult.success ? profileResult : null; const userMemories = userMemoriesResult.success ? userMemoriesResult : { results: [] }; - const projectMemoriesList = projectMemoriesListResult.success ? projectMemoriesListResult : { memories: [] }; + const currentMemories = projectMemoriesListResult.success ? (projectMemoriesListResult.memories || []) : []; + const legacyMemories = legacyProjectResult.success ? (legacyProjectResult.memories || []) : []; + const seenIds = new Set(currentMemories.map((m: any) => m.id)); + const mergedMemories = [...currentMemories, ...legacyMemories.filter((m: any) => !seenIds.has(m.id))]; + const projectMemoriesList = { success: true, memories: mergedMemories }; const projectMemories = { results: (projectMemoriesList.memories || []).map((m: any) => ({ @@ -338,22 +345,28 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { } if (scope === "project") { - const result = await supermemoryClient.searchMemories( - args.query, - tags.project - ); + const [result, legacyResult] = await Promise.all([ + supermemoryClient.searchMemories(args.query, tags.project), + tags.legacyProject + ? supermemoryClient.searchMemories(args.query, tags.legacyProject) + : Promise.resolve({ success: true, results: [] } as const), + ]); if (!result.success) { return JSON.stringify({ success: false, error: result.error || "Failed to search memories", }); } - return formatSearchResults(args.query, scope, result, args.limit); + const merged = mergeSearchResults(result, legacyResult); + return formatSearchResults(args.query, scope, merged, args.limit); } - const [userResult, projectResult] = await Promise.all([ + const [userResult, projectResult, legacyProjectResult] = await Promise.all([ supermemoryClient.searchMemories(args.query, tags.user), supermemoryClient.searchMemories(args.query, tags.project), + tags.legacyProject + ? supermemoryClient.searchMemories(args.query, tags.legacyProject) + : Promise.resolve({ success: true, results: [] } as const), ]); if (!userResult.success || !projectResult.success) { @@ -363,12 +376,14 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }); } + const mergedProject = mergeSearchResults(projectResult, legacyProjectResult); + const combined = [ ...(userResult.results || []).map((r) => ({ ...r, scope: "user" as const, })), - ...(projectResult.results || []).map((r) => ({ + ...(mergedProject.results || []).map((r) => ({ ...r, scope: "project" as const, })), @@ -414,11 +429,15 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const limit = args.limit || 20; const containerTag = scope === "user" ? tags.user : tags.project; - - const result = await supermemoryClient.listMemories( - containerTag, - limit - ); + const legacyTag = + scope === "project" ? tags.legacyProject : undefined; + + const [result, legacyListResult] = await Promise.all([ + supermemoryClient.listMemories(containerTag, limit), + legacyTag + ? supermemoryClient.listMemories(legacyTag, limit) + : Promise.resolve({ success: true, memories: [] } as const), + ]); if (!result.success) { return JSON.stringify({ @@ -427,7 +446,10 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }); } - const memories = result.memories || []; + const currentMems = result.memories || []; + const legacyMems = legacyListResult.success ? (legacyListResult.memories || []) : []; + const listSeenIds = new Set(currentMems.map((m: any) => m.id)); + const memories = [...currentMems, ...legacyMems.filter((m: any) => !listSeenIds.has(m.id))]; return JSON.stringify({ success: true, scope, @@ -492,10 +514,23 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }; }; +type SearchResult = { id: string; memory?: string; chunk?: string; similarity?: number }; +type SearchResponse = { success?: boolean; results?: SearchResult[] }; + +function mergeSearchResults(primary: SearchResponse, legacy: SearchResponse): SearchResponse { + const primaryResults = primary.results || []; + const legacyResults = legacy.success ? (legacy.results || []) : []; + const seenIds = new Set(primaryResults.map((r) => r.id)); + return { + ...primary, + results: [...primaryResults, ...legacyResults.filter((r) => !seenIds.has(r.id))], + }; +} + function formatSearchResults( query: string, scope: string | undefined, - results: { results?: Array<{ id: string; memory?: string; chunk?: string; similarity?: number }> }, + results: SearchResponse, limit?: number ): string { const memoryResults = results.results || []; diff --git a/src/services/compaction.ts b/src/services/compaction.ts index 4701beb..228264e 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -248,7 +248,7 @@ export interface CompactionContext { export function createCompactionHook( ctx: CompactionContext, - tags: { user: string; project: string }, + tags: { user: string; project: string; legacyProject?: string }, options?: CompactionOptions ) { const state: CompactionState = { @@ -262,9 +262,17 @@ export function createCompactionHook( async function fetchProjectMemoriesForCompaction(): Promise { try { - const result = await supermemoryClient.listMemories(tags.project, CONFIG.maxProjectMemories); - const memories = result.memories || []; - return memories.map((m: any) => m.summary || m.content || "").filter(Boolean); + const [result, legacyResult] = await Promise.all([ + supermemoryClient.listMemories(tags.project, CONFIG.maxProjectMemories), + tags.legacyProject + ? supermemoryClient.listMemories(tags.legacyProject, CONFIG.maxProjectMemories) + : Promise.resolve({ success: true, memories: [] } as const), + ]); + const currentMems = result.memories || []; + const legacyMems = legacyResult.success ? (legacyResult.memories || []) : []; + const seenIds = new Set(currentMems.map((m: any) => m.id)); + const allMemories = [...currentMems, ...legacyMems.filter((m: any) => !seenIds.has(m.id))]; + return allMemories.map((m: any) => m.summary || m.content || "").filter(Boolean); } catch (err) { log("[compaction] failed to fetch project memories", { error: String(err) }); return []; diff --git a/src/services/tags.ts b/src/services/tags.ts index ba9b760..a244066 100644 --- a/src/services/tags.ts +++ b/src/services/tags.ts @@ -84,9 +84,26 @@ export function getProjectTag(directory: string): string { return `${CONFIG.containerTagPrefix}_project_${sha256(directory)}`; } -export function getTags(directory: string): { user: string; project: string } { +/** + * Returns the legacy directory-hash project tag if it differs from the + * current (remote-based) tag. Used to query old memories created before + * the git-remote-based tagging was introduced. + */ +export function getLegacyProjectTag(directory: string): string | undefined { + if (CONFIG.projectContainerTag) return undefined; + + const remoteUrl = getGitRemoteUrl(directory); + if (!remoteUrl) return undefined; + + // A remote exists, so the canonical tag is remote-based. + // Return the old directory-based tag for migration reads. + return `${CONFIG.containerTagPrefix}_project_${sha256(directory)}`; +} + +export function getTags(directory: string): { user: string; project: string; legacyProject?: string } { return { user: getUserTag(), project: getProjectTag(directory), + legacyProject: getLegacyProjectTag(directory), }; } From ff063407f74d560d24cf3f587f16398df01b31ab Mon Sep 17 00:00:00 2001 From: Jim Park Date: Sat, 4 Apr 2026 23:13:13 -0700 Subject: [PATCH 4/4] fix: cap merged legacy+current memories to configured limit Merging memories from both current and legacy tags could return up to double the configured limit. Apply slice after dedup in all three merge sites: initial context injection, list tool, and compaction fetch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 6 ++++-- src/services/compaction.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8a8bf80..0c18267 100644 --- a/src/index.ts +++ b/src/index.ts @@ -141,7 +141,8 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const currentMemories = projectMemoriesListResult.success ? (projectMemoriesListResult.memories || []) : []; const legacyMemories = legacyProjectResult.success ? (legacyProjectResult.memories || []) : []; const seenIds = new Set(currentMemories.map((m: any) => m.id)); - const mergedMemories = [...currentMemories, ...legacyMemories.filter((m: any) => !seenIds.has(m.id))]; + const mergedMemories = [...currentMemories, ...legacyMemories.filter((m: any) => !seenIds.has(m.id))] + .slice(0, CONFIG.maxProjectMemories); const projectMemoriesList = { success: true, memories: mergedMemories }; const projectMemories = { @@ -449,7 +450,8 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const currentMems = result.memories || []; const legacyMems = legacyListResult.success ? (legacyListResult.memories || []) : []; const listSeenIds = new Set(currentMems.map((m: any) => m.id)); - const memories = [...currentMems, ...legacyMems.filter((m: any) => !listSeenIds.has(m.id))]; + const memories = [...currentMems, ...legacyMems.filter((m: any) => !listSeenIds.has(m.id))] + .slice(0, limit); return JSON.stringify({ success: true, scope, diff --git a/src/services/compaction.ts b/src/services/compaction.ts index 228264e..bfcea39 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -271,7 +271,8 @@ export function createCompactionHook( const currentMems = result.memories || []; const legacyMems = legacyResult.success ? (legacyResult.memories || []) : []; const seenIds = new Set(currentMems.map((m: any) => m.id)); - const allMemories = [...currentMems, ...legacyMems.filter((m: any) => !seenIds.has(m.id))]; + const allMemories = [...currentMems, ...legacyMems.filter((m: any) => !seenIds.has(m.id))] + .slice(0, CONFIG.maxProjectMemories); return allMemories.map((m: any) => m.summary || m.content || "").filter(Boolean); } catch (err) { log("[compaction] failed to fetch project memories", { error: String(err) });