diff --git a/README.md b/README.md index 3aadc15..620d227 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Run `/supermemory-init` to have the agent explore and memorize the codebase. On first message, the agent receives (invisible to user): -- User profile (cross-project preferences) +- Personal profile for the current project - Project memories (all project knowledge) - Relevant user memories (semantic search) @@ -193,19 +193,28 @@ The `supermemory` tool is available to the agent: | `list` | `scope?`, `limit?` | List memories | | `forget` | `memoryId`, `scope?` | Delete memory | -**Scopes:** `user` (cross-project), `project` (default) +**Scopes:** `user` (personal memories for the current project), `project` (default) **Types:** `project-config`, `architecture`, `error-solution`, `preference`, `learned-pattern`, `conversation` -OpenCode sends entity context when saving memories so Supermemory can extract -different facts for user profile memories versus project/codebase knowledge. +OpenCode sends the same shared coding-agent entity context as Claude Code and +Codex. Personal and project memories are distinguished with `sm_scope` +metadata inside the shared repository container. ## Memory Scoping -| Scope | Tag | Persists | -| ------- | -------------------------------------- | ------------ | -| User | `opencode_user_{sha256(git email)}` | All projects | -| Project | `opencode_project_{sha256(directory)}` | This project | +| Scope | Tag | Metadata | +| ------- | ------------------------------------------- | ----------------------- | +| User | `repo_{project-name}__{repository-hash}` | `sm_scope: "personal"` | +| Project | `repo_{project-name}__{repository-hash}` | `sm_scope: "project"` | + +The repository hash comes from the normalized Git `origin` remote, so Claude +Code, Codex, and OpenCode use the same container for the same repository. +Repositories with the same name but different remotes remain isolated. Without +an origin remote, OpenCode falls back to the repository's real filesystem path. +OpenCode also reads previous `user_project_*`, `repo_`, +`claudecode_project_*`, `codex_user_*`, `codex_project_*`, `opencode_user_*`, +and `opencode_project_*` containers, so upgrading does not require a migration. ## Configuration @@ -234,10 +243,10 @@ Create `~/.config/opencode/supermemory.jsonc`: // Include user profile in context "injectProfile": true, - // Prefix for container tags (used when userContainerTag/projectContainerTag not set) + // Legacy prefix retained when reading containers made by older versions "containerTagPrefix": "opencode", - // Optional: Set exact user container tag (overrides auto-generated tag) + // Optional legacy personal container to keep reading "userContainerTag": "my-custom-user-tag", // Optional: Set exact project container tag (overrides auto-generated tag) @@ -255,26 +264,28 @@ All fields optional. Env var `SUPERMEMORY_API_KEY` takes precedence over config ### Container Tag Selection -By default, container tags are auto-generated using `containerTagPrefix` plus a hash: +By default, new writes use: -- User tag: `{prefix}_user_{hash(git_email)}` -- Project tag: `{prefix}_project_{hash(directory)}` +- Repository tag: `repo_{project-name}__{hash(normalized-origin-remote)}` +- No origin remote: `repo_{project-name}__{hash(real-repository-path)}` -You can override this by specifying exact container tags: +Older `{prefix}_user_*` and `{prefix}_project_*` containers remain readable. +`userContainerTag` is treated as a legacy personal read. You can still override +the unified write container with `projectContainerTag`: ```jsonc { - // Use a specific container tag for user memories + // Continue reading a personal container made by an older version "userContainerTag": "my-team-workspace", - // Use a specific container tag for project memories + // Override the unified container used for new writes "projectContainerTag": "my-awesome-project", } ``` This is useful when you want to: -- Share memories across team members (same `userContainerTag`) +- Preserve a legacy personal memory container - Sync memories between different machines for the same project - Organize memories using your own naming scheme - Integrate with existing Supermemory container tags from other tools diff --git a/package.json b/package.json index 06136ab..c8f2aba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-supermemory", - "version": "2.0.8", + "version": "2.0.9", "description": "OpenCode plugin that gives coding agents persistent memory using Supermemory", "type": "module", "main": "dist/index.js", diff --git a/src/cli.ts b/src/cli.ts index 3073493..365a497 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -57,7 +57,7 @@ How the codebase works and why: - Known issues and their solutions **User-scoped** (\`scope: "user"\`): -- Personal coding preferences across all projects +- Personal coding preferences relevant to this project - Communication style preferences - General workflow habits @@ -598,11 +598,12 @@ async function status(): Promise { lines.push(`Connected: ${isConfigured() ? "checking..." : "no"}`); lines.push(`API key: ${maskKey(SUPERMEMORY_API_KEY)} (${getKeySource()})`); lines.push(`API URL: ${apiUrl}`); - lines.push("Memory scope: current project + user profile"); + lines.push("Memory scope: unified project container with personal/project metadata"); lines.push(`Recall mode: ${CONFIG.autoRecallEveryPrompt ? "auto-recall on every prompt" : "session/event based"}`); lines.push(`Capture cadence: ${CONFIG.captureEveryNTurns > 0 ? `every ${CONFIG.captureEveryNTurns} turn${CONFIG.captureEveryNTurns === 1 ? "" : "s"} + session end` : "session end only"}`); - lines.push(`Project tag: ${tags.project}`); - lines.push(`User tag: ${tags.user}`); + lines.push(`Project container: ${tags.canonical}`); + lines.push(`Personal reads: ${tags.personalReads.join(", ")}`); + lines.push(`Project reads: ${tags.projectReads.join(", ")}`); if (!isConfigured()) { lines.push(""); @@ -613,7 +614,11 @@ async function status(): Promise { const client = new SupermemoryClient(); const [profileResult, accountInfo] = await Promise.all([ - client.getProfile(tags.user), + client.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + ), getAccountInfo(apiUrl), ]); diff --git a/src/config.ts b/src/config.ts index b91b1b2..3943b3b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,7 @@ import { stripJsoncComments } from "./services/jsonc.js"; import { loadCredentials } from "./services/auth.js"; const CONFIG_DIR = join(homedir(), ".config", "opencode"); -export const PLUGIN_VERSION = "2.0.8"; +export const PLUGIN_VERSION = "2.0.9"; const CONFIG_FILES = [ join(CONFIG_DIR, "supermemory.jsonc"), join(CONFIG_DIR, "supermemory.json"), diff --git a/src/index.ts b/src/index.ts index 622bf4e..bfe27a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,7 @@ import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import type { Part } from "@opencode-ai/sdk"; import { tool } from "@opencode-ai/plugin"; -import { - PROJECT_ENTITY_CONTEXT, - USER_ENTITY_CONTEXT, -} from "./services/entity-context.js"; +import { AGENT_ENTITY_CONTEXT } from "./services/entity-context.js"; import { supermemoryClient } from "./services/client.js"; import { formatContextForPrompt } from "./services/context.js"; import { getTags } from "./services/tags.js"; @@ -27,7 +24,7 @@ The user wants you to remember something. You MUST use the \`supermemory\` tool Extract the key information the user wants remembered and save it as a concise, searchable memory. - Use \`scope: "project"\` for project-specific preferences (e.g., "run lint with tests") -- Use \`scope: "user"\` for cross-project preferences (e.g., "prefers concise responses") +- Use \`scope: "user"\` for personal preferences in this project (e.g., "prefers concise responses") - Choose an appropriate \`type\`: "preference", "project-config", "learned-pattern", etc. DO NOT skip this step. The user explicitly asked you to remember.`; @@ -146,9 +143,24 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { if (CONFIG.autoRecallEveryPrompt) { const [profileResult, userMemoriesResult, projectMemoriesListResult] = await Promise.all([ - supermemoryClient.getProfile(tags.user, userMessage), - supermemoryClient.searchMemories(userMessage, tags.user), - supermemoryClient.listMemories(tags.project, CONFIG.maxProjectMemories), + supermemoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + userMessage, + ), + supermemoryClient.searchMemoriesScoped( + userMessage, + tags.canonical, + tags.personalReads, + "personal", + ), + supermemoryClient.listMemoriesScoped( + tags.canonical, + tags.projectReads, + "project", + CONFIG.maxProjectMemories, + ), ]); const profile = profileResult.success ? profileResult : null; @@ -173,7 +185,11 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { projectMemories ); } else { - const profileResult = await supermemoryClient.getProfile(tags.user); + const profileResult = await supermemoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + ); const profile = profileResult.success ? profileResult : null; memoryContext = formatContextForPrompt(profile, { results: [] }, { results: [] }); } @@ -283,7 +299,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { }, ], scopes: { - user: "Cross-project preferences and knowledge", + user: "Personal preferences and knowledge for this project", project: "Project-specific knowledge (default)", }, types: [ @@ -314,16 +330,20 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { } const scope = args.scope || "project"; - const containerTag = - scope === "user" ? tags.user : tags.project; - const entityContext = - scope === "user" ? USER_ENTITY_CONTEXT : PROJECT_ENTITY_CONTEXT; + const internalScope = + scope === "user" ? "personal" : "project"; const result = await supermemoryClient.addMemory( sanitizedContent, - containerTag, - { type: args.type }, - { entityContext } + tags.canonical, + { + type: args.type, + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: internalScope, + sm_capture_mode: "tool", + }, + { entityContext: AGENT_ENTITY_CONTEXT } ); if (!result.success) { @@ -353,9 +373,11 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { const scope = args.scope; if (scope === "user") { - const result = await supermemoryClient.searchMemories( + const result = await supermemoryClient.searchMemoriesScoped( args.query, - tags.user + tags.canonical, + tags.personalReads, + "personal", ); if (!result.success) { return JSON.stringify({ @@ -367,9 +389,11 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { } if (scope === "project") { - const result = await supermemoryClient.searchMemories( + const result = await supermemoryClient.searchMemoriesScoped( args.query, - tags.project + tags.canonical, + tags.projectReads, + "project", ); if (!result.success) { return JSON.stringify({ @@ -380,46 +404,30 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { return formatSearchResults(args.query, scope, result, args.limit); } - const [userResult, projectResult] = await Promise.all([ - supermemoryClient.searchMemories(args.query, tags.user), - supermemoryClient.searchMemories(args.query, tags.project), - ]); - - if (!userResult.success || !projectResult.success) { + const result = await supermemoryClient.searchMemoriesMany( + args.query, + tags.allReads, + ); + if (!result.success) { return JSON.stringify({ success: false, - error: userResult.error || projectResult.error || "Failed to search memories", + error: result.error || "Failed to search memories", }); } - - const combined = [ - ...(userResult.results || []).map((r) => ({ - ...r, - scope: "user" as const, - })), - ...(projectResult.results || []).map((r) => ({ - ...r, - scope: "project" as const, - })), - ].sort((a, b) => (b.similarity ?? 0) - (a.similarity ?? 0)); - - return JSON.stringify({ - success: true, - query: args.query, - count: combined.length, - results: combined.slice(0, args.limit || 10).map((r) => ({ - id: r.id, - content: r.memory || r.chunk, - similarity: Math.round((r.similarity ?? 0) * 100), - scope: r.scope, - })), - }); + return formatSearchResults( + args.query, + undefined, + result, + args.limit, + ); } case "profile": { - const result = await supermemoryClient.getProfile( - tags.user, - args.query + const result = await supermemoryClient.getProfileScoped( + tags.canonical, + tags.personalReads, + "personal", + args.query, ); if (!result.success) { @@ -441,12 +449,16 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { case "list": { const scope = args.scope || "project"; const limit = args.limit || 20; - const containerTag = - scope === "user" ? tags.user : tags.project; - - const result = await supermemoryClient.listMemories( - containerTag, - limit + const internalScope = + scope === "user" ? "personal" : "project"; + const readTags = + scope === "user" ? tags.personalReads : tags.projectReads; + + const result = await supermemoryClient.listMemoriesScoped( + tags.canonical, + readTags, + internalScope, + limit, ); if (!result.success) { @@ -524,7 +536,7 @@ export const SupermemoryPlugin: Plugin = async (ctx: PluginInput) => { function formatSearchResults( query: string, scope: string | undefined, - results: { results?: Array<{ id: string; memory?: string; chunk?: string; similarity?: number }> }, + results: { results?: Array<{ id?: string; memory?: string; chunk?: string; similarity?: number }> }, limit?: number ): string { const memoryResults = results.results || []; diff --git a/src/services/auth.ts b/src/services/auth.ts index 4aab6e5..90b0a4d 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -162,7 +162,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { hostname: `opencode - ${hostname()}`, os: `${platform()}-${arch()}`, cwd: process.cwd(), - cli_version: "2.0.8", + cli_version: "2.0.9", }); const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; diff --git a/src/services/client.ts b/src/services/client.ts index d46aff0..e46e13c 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -1,6 +1,17 @@ import Supermemory from "supermemory"; -import { CONFIG, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured } from "../config.js"; +import { + CONFIG, + PLUGIN_VERSION, + SUPERMEMORY_API_KEY, + getApiBaseUrl, + isConfigured, +} from "../config.js"; import { log } from "./logger.js"; +import { + mergeListResponses, + mergeProfileResponses, + mergeSearchResponses, +} from "./result-merge.js"; import type { ConversationIngestResponse, ConversationMessage, @@ -11,13 +22,79 @@ const TIMEOUT_MS = 30000; const MAX_CONVERSATION_CHARS = 100_000; const OPENCODE_SOURCE = "opencode"; +export type MemoryScope = "personal" | "project"; + +export interface SearchResultItem { + id?: string; + memory?: string; + content?: string; + chunk?: string; + context?: unknown; + score?: number; + similarity?: number; + title?: string; + updatedAt?: string; + metadata?: Record | null; + containerTag?: string; +} + +export interface SearchResponse { + success: boolean; + results?: SearchResultItem[]; + total?: number; + timing?: number; + error?: string; +} + +export interface ProfileResponse { + success: boolean; + profile: { static: string[]; dynamic: string[] } | null; + searchResults?: { + results: SearchResultItem[]; + total: number; + timing?: number; + }; + error?: string; +} + +export interface ListMemoryItem { + id: string; + summary?: string | null; + content?: string | null; + title?: string | null; + metadata?: Record | null; + createdAt?: string; + updatedAt?: string; + [key: string]: unknown; +} + +export interface ListResponse { + success: boolean; + memories: ListMemoryItem[]; + pagination: { + currentPage: number; + totalItems: number; + totalPages: number; + }; + error?: string; +} + +function getScopeFilters(scope: MemoryScope) { + return { + AND: [{ key: "sm_scope", value: scope, filterType: "metadata" as const }], + }; +} + +function supportsScopedCanonicalTag(containerTag: string): boolean { + return /^repo_.+__[0-9a-f]{16}$/i.test(containerTag); +} + function withTimeout(promise: Promise, ms: number): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms) - ), - ]); + let id: ReturnType; + const timeout = new Promise((_, reject) => { + id = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(id)); } export class SupermemoryClient { @@ -31,7 +108,7 @@ export class SupermemoryClient { .map((part) => part.type === "text" ? part.text - : `[image] ${part.imageUrl.url}` + : `[image] ${part.imageUrl.url}`, ) .join("\n"); @@ -42,9 +119,14 @@ export class SupermemoryClient { return `[${message.role}] ${trimmed}`; } - private formatConversationTranscript(messages: ConversationMessage[]): string { + private formatConversationTranscript( + messages: ConversationMessage[], + ): string { return messages - .map((message, idx) => `${idx + 1}. ${this.formatConversationMessage(message)}`) + .map( + (message, idx) => + `${idx + 1}. ${this.formatConversationMessage(message)}`, + ) .join("\n"); } @@ -53,23 +135,25 @@ export class SupermemoryClient { if (!isConfigured()) { throw new Error("SUPERMEMORY_API_KEY not set"); } - // `x-sm-source` is read by mono's API to attribute searches and - // writes to the OpenCode plugin in PostHog / `document.source`. this.client = new Supermemory({ apiKey: SUPERMEMORY_API_KEY, baseURL: getApiBaseUrl(), defaultHeaders: { "x-sm-source": OPENCODE_SOURCE }, }); - this.client.settings.update({ - shouldLLMFilter: true, - filterPrompt: CONFIG.filterPrompt - }) + void this.client.settings.update({ + shouldLLMFilter: true, + filterPrompt: CONFIG.filterPrompt, + }); } return this.client; } - async searchMemories(query: string, containerTag: string) { - log("searchMemories: start", { containerTag }); + async searchMemories( + query: string, + containerTag: string, + scope?: MemoryScope, + ): Promise { + log("searchMemories: start", { containerTag, scope }); try { const result = await withTimeout( this.getClient().search.memories({ @@ -77,43 +161,173 @@ export class SupermemoryClient { containerTag, threshold: CONFIG.similarityThreshold, limit: CONFIG.maxMemories, - searchMode: "hybrid" + searchMode: "hybrid", + filters: scope ? getScopeFilters(scope) : undefined, }), - TIMEOUT_MS + TIMEOUT_MS, ); - log("searchMemories: success", { count: result.results?.length || 0 }); - return { success: true as const, ...result }; + const results = (result.results as SearchResultItem[]).map((item) => ({ + ...item, + containerTag, + })); + log("searchMemories: success", { count: results.length }); + return { + success: true, + results, + total: result.total, + timing: result.timing, + }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); log("searchMemories: error", { error: errorMessage }); - return { success: false as const, error: errorMessage, results: [], total: 0, timing: 0 }; + return { + success: false, + error: errorMessage, + results: [], + total: 0, + timing: 0, + }; } } - async getProfile(containerTag: string, query?: string) { - log("getProfile: start", { containerTag }); + async searchMemoriesMany( + query: string, + containerTags: string[], + ): Promise { + const uniqueTags = [...new Set(containerTags.filter(Boolean))]; + const responses = await Promise.all( + uniqueTags.map((containerTag) => + this.searchMemories(query, containerTag), + ), + ); + return mergeSearchResponses(responses, CONFIG.maxMemories); + } + + async searchMemoriesScoped( + query: string, + canonicalTag: string, + containerTags: string[], + scope: MemoryScope, + ): Promise { + const legacyTags = [ + ...new Set( + containerTags.filter((tag) => tag && tag !== canonicalTag), + ), + ]; + const responses = await Promise.all([ + this.searchMemories( + query, + canonicalTag, + supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + ), + ...legacyTags.map((containerTag) => + this.searchMemories(query, containerTag), + ), + ]); + return mergeSearchResponses(responses, CONFIG.maxMemories); + } + + async getProfile( + containerTag: string, + query?: string, + scope?: MemoryScope, + ): Promise { + log("getProfile: start", { containerTag, scope }); try { const result = await withTimeout( - this.getClient().profile({ - containerTag, - q: query, - }), - TIMEOUT_MS + this.getClient().profile( + { + containerTag, + q: query, + filters: scope ? getScopeFilters(scope) : undefined, + } as Parameters[0], + ), + TIMEOUT_MS, ); - log("getProfile: success", { hasProfile: !!result?.profile }); - return { success: true as const, ...result }; + const searchResults = result.searchResults + ? { + results: ( + result.searchResults.results as SearchResultItem[] + ).map((item) => ({ + ...item, + memory: + item.memory ?? + item.content ?? + String(item.context ?? ""), + containerTag, + })), + total: result.searchResults.total, + timing: result.searchResults.timing, + } + : undefined; + log("getProfile: success", { hasProfile: !!result.profile }); + return { + success: true, + profile: { + static: result.profile?.static ?? [], + dynamic: result.profile?.dynamic ?? [], + }, + searchResults, + }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); log("getProfile: error", { error: errorMessage }); - return { success: false as const, error: errorMessage, profile: null }; + return { + success: false, + error: errorMessage, + profile: null, + }; } } + async getProfileMany( + containerTags: string[], + query?: string, + ): Promise { + const uniqueTags = [...new Set(containerTags.filter(Boolean))]; + const responses = await Promise.all( + uniqueTags.map((containerTag) => + this.getProfile(containerTag, query), + ), + ); + return mergeProfileResponses(responses, CONFIG.maxMemories); + } + + async getProfileScoped( + canonicalTag: string, + containerTags: string[], + scope: MemoryScope, + query?: string, + ): Promise { + const legacyTags = [ + ...new Set( + containerTags.filter((tag) => tag && tag !== canonicalTag), + ), + ]; + const responses = await Promise.all([ + this.getProfile( + canonicalTag, + query, + supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + ), + ...legacyTags.map((containerTag) => + this.getProfile(containerTag, query), + ), + ]); + return mergeProfileResponses(responses, CONFIG.maxMemories); + } + async addMemory( content: string, containerTag: string, - metadata?: { type?: MemoryType; tool?: string; [key: string]: unknown }, - options?: { customId?: string; entityContext?: string } + metadata?: { + type?: MemoryType; + tool?: string; + [key: string]: unknown; + }, + options?: { customId?: string; entityContext?: string }, ) { log("addMemory: start", { containerTag, @@ -122,14 +336,15 @@ export class SupermemoryClient { hasEntityContext: !!options?.entityContext, }); try { - // Always stamp `sm_source` so mono's `document.source` column attributes - // these writes to the OpenCode plugin. Caller-provided metadata wins on - // conflicts. - const mergedMetadata = { - sm_source: OPENCODE_SOURCE, - sm_capture_mode: metadata?.sm_capture_mode ?? "tool", - ...(metadata ?? {}), - } as unknown as Record; + const mergedMetadata = Object.fromEntries( + Object.entries({ + sm_source: OPENCODE_SOURCE, + sm_client: OPENCODE_SOURCE, + sm_plugin_version: PLUGIN_VERSION, + sm_capture_mode: metadata?.sm_capture_mode ?? "tool", + ...(metadata ?? {}), + }).filter(([, value]) => value !== undefined), + ) as Record; const payload: { content: string; @@ -151,12 +366,13 @@ export class SupermemoryClient { const result = await withTimeout( this.getClient().memories.add(payload), - TIMEOUT_MS + TIMEOUT_MS, ); log("addMemory: success", { id: result.id }); return { success: true as const, ...result }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); log("addMemory: error", { error: errorMessage }); return { success: false as const, error: errorMessage }; } @@ -167,39 +383,93 @@ export class SupermemoryClient { try { await withTimeout( this.getClient().memories.delete(memoryId), - TIMEOUT_MS + TIMEOUT_MS, ); log("deleteMemory: success", { memoryId }); - return { success: true }; + return { success: true as const }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); log("deleteMemory: error", { memoryId, error: errorMessage }); - return { success: false, error: errorMessage }; + return { success: false as const, error: errorMessage }; } } - async listMemories(containerTag: string, limit = 20) { - log("listMemories: start", { containerTag, limit }); + async listMemories( + containerTag: string, + limit = 20, + scope?: MemoryScope, + ): Promise { + log("listMemories: start", { containerTag, limit, scope }); try { const result = await withTimeout( this.getClient().memories.list({ containerTags: [containerTag], + filters: scope ? getScopeFilters(scope) : undefined, limit, order: "desc", sort: "createdAt", includeContent: true, }), - TIMEOUT_MS + TIMEOUT_MS, ); - log("listMemories: success", { count: result.memories?.length || 0 }); - return { success: true as const, ...result }; + const memories = result.memories as unknown as ListMemoryItem[]; + log("listMemories: success", { count: memories.length }); + return { + success: true, + memories, + pagination: result.pagination, + }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); log("listMemories: error", { error: errorMessage }); - return { success: false as const, error: errorMessage, memories: [], pagination: { currentPage: 1, totalItems: 0, totalPages: 0 } }; + return { + success: false, + error: errorMessage, + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + }; } } + async listMemoriesMany( + containerTags: string[], + limit = 20, + ): Promise { + const uniqueTags = [...new Set(containerTags.filter(Boolean))]; + const responses = await Promise.all( + uniqueTags.map((containerTag) => + this.listMemories(containerTag, limit), + ), + ); + return mergeListResponses(responses, limit); + } + + async listMemoriesScoped( + canonicalTag: string, + containerTags: string[], + scope: MemoryScope, + limit = 20, + ): Promise { + const legacyTags = [ + ...new Set( + containerTags.filter((tag) => tag && tag !== canonicalTag), + ), + ]; + const responses = await Promise.all([ + this.listMemories( + canonicalTag, + limit, + supportsScopedCanonicalTag(canonicalTag) ? scope : undefined, + ), + ...legacyTags.map((containerTag) => + this.listMemories(containerTag, limit), + ), + ]); + return mergeListResponses(responses, limit); + } + async ingestConversation( conversationId: string, messages: ConversationMessage[], @@ -208,7 +478,7 @@ export class SupermemoryClient { options?: { defaultEntityContext?: string; entityContextByContainerTag?: Record; - } + }, ) { log("ingestConversation: start", { conversationId, @@ -220,9 +490,14 @@ export class SupermemoryClient { return { success: false as const, error: "No messages to ingest" }; } - const uniqueTags = [...new Set(containerTags)].filter((tag) => tag.length > 0); + const uniqueTags = [ + ...new Set(containerTags), + ].filter((tag) => tag.length > 0); if (uniqueTags.length === 0) { - return { success: false as const, error: "At least one containerTag is required" }; + return { + success: false as const, + error: "At least one containerTag is required", + }; } const transcript = this.formatConversationTranscript(messages); @@ -245,7 +520,8 @@ export class SupermemoryClient { for (const tag of uniqueTags) { const entityContext = - options?.entityContextByContainerTag?.[tag] ?? options?.defaultEntityContext; + options?.entityContextByContainerTag?.[tag] ?? + options?.defaultEntityContext; const result = await this.addMemory(content, tag, ingestMetadata, { ...(entityContext ? { entityContext } : {}), }); @@ -257,7 +533,10 @@ export class SupermemoryClient { } if (savedIds.length === 0) { - log("ingestConversation: error", { conversationId, error: firstError }); + log("ingestConversation: error", { + conversationId, + error: firstError, + }); return { success: false as const, error: firstError || "Failed to ingest conversation", @@ -285,7 +564,6 @@ export class SupermemoryClient { storedMemoryIds: savedIds, }; } - } export const supermemoryClient = new SupermemoryClient(); diff --git a/src/services/compaction.ts b/src/services/compaction.ts index 924c8b8..f925892 100644 --- a/src/services/compaction.ts +++ b/src/services/compaction.ts @@ -1,10 +1,11 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { PROJECT_ENTITY_CONTEXT } from "./entity-context.js"; +import { AGENT_ENTITY_CONTEXT } from "./entity-context.js"; import { supermemoryClient } from "./client.js"; import { log } from "./logger.js"; import { CONFIG } from "../config.js"; +import type { ResolvedTags } from "./tags.js"; const MESSAGE_STORAGE = join(homedir(), ".opencode", "messages"); const PART_STORAGE = join(homedir(), ".opencode", "parts"); @@ -249,7 +250,7 @@ export interface CompactionContext { export function createCompactionHook( ctx: CompactionContext, - tags: { user: string; project: string }, + tags: ResolvedTags, options?: CompactionOptions ) { const state: CompactionState = { @@ -263,7 +264,12 @@ export function createCompactionHook( async function fetchProjectMemoriesForCompaction(): Promise { try { - const result = await supermemoryClient.listMemories(tags.project, CONFIG.maxProjectMemories); + const result = await supermemoryClient.listMemoriesScoped( + tags.canonical, + tags.projectReads, + "project", + CONFIG.maxProjectMemories, + ); const memories = result.memories || []; return memories.map((m: any) => m.summary || m.content || "").filter(Boolean); } catch (err) { @@ -301,9 +307,16 @@ export function createCompactionHook( try { const result = await supermemoryClient.addMemory( `[Session Summary]\n${summaryContent}`, - tags.project, - { type: "conversation" }, - { entityContext: PROJECT_ENTITY_CONTEXT } + tags.canonical, + { + type: "conversation", + project: tags.projectName, + sm_project_id: tags.projectId, + sm_scope: "personal", + sm_capture_mode: "compaction", + sessionId: sessionID, + }, + { entityContext: AGENT_ENTITY_CONTEXT } ); if (result.success) { diff --git a/src/services/context.ts b/src/services/context.ts index 3d6a18e..06cc3d6 100644 --- a/src/services/context.ts +++ b/src/services/context.ts @@ -1,4 +1,4 @@ -import type { ProfileResponse } from "supermemory/resources"; +import type { ProfileResponse } from "./client.js"; import { CONFIG } from "../config.js"; interface MemoryResultMinimal { diff --git a/src/services/entity-context.ts b/src/services/entity-context.ts index 1aa9147..4f80572 100644 --- a/src/services/entity-context.ts +++ b/src/services/entity-context.ts @@ -1,24 +1,24 @@ -export const USER_ENTITY_CONTEXT = `Developer coding session transcript for a persistent user profile. +export const AGENT_ENTITY_CONTEXT = `Shared coding-agent memory for one software repository. -EXTRACT: -- User preferences: preferred languages, frameworks, libraries, editors, workflows, and communication style -- Stable habits: testing style, code review expectations, formatting preferences, privacy preferences -- Repeated personal decisions: tools the user consistently chooses or avoids -- Long-lived learnings: concepts the user learned or wants remembered across projects - -SKIP: -- One-off assistant suggestions the user did not accept -- Low-level implementation details that only matter inside the current repository`; - -export const PROJECT_ENTITY_CONTEXT = `Project/codebase knowledge from OpenCode coding sessions. +RULES: +- Preserve durable context that helps Claude Code, Codex, or OpenCode continue the work +- Condense assistant responses into decisions, outcomes, and reusable knowledge +- Keep user preferences and project facts concise and independently understandable EXTRACT: -- Architecture: repo structure, services, modules, data flow, and integration boundaries -- Conventions: naming, component patterns, API patterns, testing practices, and style rules -- Decisions: chosen approaches, tradeoffs, migrations, and rejected alternatives -- Setup: commands, environment requirements, deployment notes, and debugging workflows -- Implementation lessons: bugs fixed, root causes, and reusable project-specific context +- User preferences, accepted decisions, durable workflows, actions, and learnings +- Architecture: "uses monorepo with turborepo", "API in /apps/api" +- Conventions: "components in PascalCase", "hooks prefixed with use" +- Patterns: "all API routes use withAuth wrapper", "errors thrown as ApiError" +- Setup: "requires .env with DATABASE_URL", "run pnpm db:migrate first" +- Decisions: "chose Drizzle over Prisma for performance", "using RSC for data fetching" SKIP: -- Verbatim assistant explanations unless they became an accepted project decision -- Transient command output with no lasting project value`; +- Generic assistant suggestions the user did not accept +- Transient command output and low-value implementation chatter +- Granular details that do not help future work`; + +// Backwards-compatible aliases. Entity context is stored on the container, so +// every agent must send the same combined context for the shared repository tag. +export const USER_ENTITY_CONTEXT = AGENT_ENTITY_CONTEXT; +export const PROJECT_ENTITY_CONTEXT = AGENT_ENTITY_CONTEXT; diff --git a/src/services/result-merge.ts b/src/services/result-merge.ts new file mode 100644 index 0000000..121dce2 --- /dev/null +++ b/src/services/result-merge.ts @@ -0,0 +1,174 @@ +import type { + ListMemoryItem, + ListResponse, + ProfileResponse, + SearchResponse, + SearchResultItem, +} from "./client.js"; + +function normalize(value: unknown): string { + return String(value ?? "").toLowerCase().trim(); +} + +function dedupe(items: T[], getKey: (item: T) => string): T[] { + const seen = new Set(); + return items.filter((item) => { + const key = normalize(getKey(item)); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function memoryText(result: SearchResultItem): string { + return ( + result.memory ?? + result.chunk ?? + result.content ?? + String(result.context ?? "") + ); +} + +function searchKey(result: SearchResultItem): string { + const content = normalize(memoryText(result)); + if (content) return `content:${content}`; + return result.id ? `id:${result.id}` : ""; +} + +function score(result: SearchResultItem): number { + return result.similarity ?? result.score ?? -1; +} + +export function mergeSearchResponses( + responses: SearchResponse[], + limit: number, +): SearchResponse { + const successful = responses.filter((response) => response.success); + if (successful.length === 0) { + return { + success: false, + error: + responses.find((response) => response.error)?.error || + "All container searches failed", + results: [], + total: 0, + timing: 0, + }; + } + + const results = successful + .flatMap((response) => response.results ?? []) + .sort((a, b) => { + const scoreDiff = score(b) - score(a); + if (scoreDiff !== 0) return scoreDiff; + const aTime = a.updatedAt ? Date.parse(a.updatedAt) : 0; + const bTime = b.updatedAt ? Date.parse(b.updatedAt) : 0; + return bTime - aTime; + }); + const merged = dedupe(results, searchKey).slice(0, limit); + + return { + success: true, + results: merged, + total: merged.length, + timing: Math.max( + 0, + ...successful.map((response) => response.timing ?? 0), + ), + }; +} + +export function mergeProfileResponses( + responses: ProfileResponse[], + limit: number, +): ProfileResponse { + const successful = responses.filter( + (response) => response.success && response.profile, + ); + if (successful.length === 0) { + return { + success: false, + error: + responses.find((response) => response.error)?.error || + "All memory profiles failed", + profile: null, + }; + } + + const staticFacts = dedupe( + successful.flatMap((response) => response.profile?.static ?? []), + (fact) => fact, + ); + const staticKeys = new Set(staticFacts.map(normalize)); + const dynamicFacts = dedupe( + successful.flatMap((response) => response.profile?.dynamic ?? []), + (fact) => fact, + ).filter((fact) => !staticKeys.has(normalize(fact))); + const mergedSearch = mergeSearchResponses( + successful.map((response) => ({ + success: true, + results: response.searchResults?.results ?? [], + total: response.searchResults?.total ?? 0, + timing: response.searchResults?.timing, + })), + limit, + ); + + return { + success: true, + profile: { static: staticFacts, dynamic: dynamicFacts }, + searchResults: + mergedSearch.results && mergedSearch.results.length > 0 + ? { + results: mergedSearch.results, + total: mergedSearch.total ?? mergedSearch.results.length, + timing: mergedSearch.timing, + } + : undefined, + }; +} + +function listKey(memory: ListMemoryItem): string { + const content = normalize(memory.summary ?? memory.content ?? memory.title); + if (content) return `content:${content}`; + return memory.id ? `id:${memory.id}` : ""; +} + +function listTimestamp(memory: ListMemoryItem): number { + const timestamp = memory.updatedAt ?? memory.createdAt; + return timestamp ? Date.parse(timestamp) : 0; +} + +export function mergeListResponses( + responses: ListResponse[], + limit: number, +): ListResponse { + const successful = responses.filter((response) => response.success); + if (successful.length === 0) { + return { + success: false, + error: + responses.find((response) => response.error)?.error || + "All memory lists failed", + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + }; + } + + const memories = dedupe( + successful + .flatMap((response) => response.memories ?? []) + .sort((a, b) => listTimestamp(b) - listTimestamp(a)), + listKey, + ).slice(0, limit); + + return { + success: true, + memories, + pagination: { + currentPage: 1, + totalItems: memories.length, + totalPages: memories.length > 0 ? 1 : 0, + }, + }; +} diff --git a/src/services/tags.ts b/src/services/tags.ts index 964d111..2f67313 100644 --- a/src/services/tags.ts +++ b/src/services/tags.ts @@ -1,48 +1,366 @@ import { createHash } from "node:crypto"; import { execSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { hostname, homedir } from "node:os"; +import { basename, dirname, join, resolve, sep } from "node:path"; import { CONFIG } from "../config.js"; function sha256(input: string): string { return createHash("sha256").update(input).digest("hex").slice(0, 16); } -export function getGitEmail(): string | null { +const repoInfoCache = new Map< + string, + { name: string | null; normalizedRemote: string | null } +>(); + +function getGitRoot(directory: string): string | null { + const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; + try { - const email = execSync("git config user.email", { encoding: "utf-8" }).trim(); + if (isolateWorktrees) { + const gitRoot = execSync("git rev-parse --show-toplevel", { + cwd: directory, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return gitRoot || null; + } + + const gitCommonDir = execSync("git rev-parse --git-common-dir", { + cwd: directory, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + if (gitCommonDir === ".git") { + const gitRoot = execSync("git rev-parse --show-toplevel", { + cwd: directory, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return gitRoot || null; + } + + const resolved = resolve(directory, gitCommonDir); + if (basename(resolved) === ".git" && !resolved.includes(`${sep}.git${sep}`)) { + return dirname(resolved); + } + + const gitRoot = execSync("git rev-parse --show-toplevel", { + cwd: directory, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return gitRoot || null; + } catch { + return null; + } +} + +function getProjectBasePath(directory: string): string { + return getGitRoot(directory) || resolve(directory); +} + +function getGitEmail(directory: string): string | null { + try { + const email = execSync("git config user.email", { + cwd: getProjectBasePath(directory), + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); return email || null; } catch { return null; } } -export function getUserTag(): string { - // If userContainerTag is explicitly set, use it - if (CONFIG.userContainerTag) { - return CONFIG.userContainerTag; +export function normalizeGitRemote(remoteUrl: string): string | null { + const raw = remoteUrl.trim(); + if (!raw) return null; + + let normalized: string; + if (/^[a-z][a-z\d+.-]*:\/\//i.test(raw)) { + try { + const parsed = new URL(raw); + normalized = + parsed.protocol === "file:" + ? `file:${decodeURIComponent(parsed.pathname)}` + : `${parsed.hostname.toLowerCase()}${ + parsed.port ? `:${parsed.port}` : "" + }/${parsed.pathname.replace(/^\/+/, "")}`; + } catch { + normalized = raw; + } + } else { + const scpStyle = raw.match(/^(?:[^@/]+@)?([^:]+):(.+)$/); + normalized = + scpStyle?.[1] && scpStyle[2] + ? `${scpStyle[1].toLowerCase()}/${scpStyle[2]}` + : `file:${resolve(raw)}`; + } + + return normalized + .replace(/[?#].*$/, "") + .replace(/\/+$/, "") + .replace(/\.git$/i, "") + .replace(/\/{2,}/g, "/") + .toLowerCase(); +} + +function getGitRepoInfo(directory: string): { + name: string | null; + normalizedRemote: string | null; +} { + const cached = repoInfoCache.get(directory); + if (cached) return cached; + + try { + const remoteUrl = execSync("git remote get-url origin", { + cwd: directory, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + const normalizedRemote = normalizeGitRemote(remoteUrl); + const displayRemote = remoteUrl.replace(/\/+$/, "").replace(/\.git$/i, ""); + const separator = Math.max( + displayRemote.lastIndexOf("/"), + displayRemote.lastIndexOf(":"), + ); + const result = { + name: displayRemote.slice(separator + 1) || null, + normalizedRemote, + }; + repoInfoCache.set(directory, result); + return result; + } catch { + const result = { name: null, normalizedRemote: null }; + repoInfoCache.set(directory, result); + return result; } +} + +function getGitRepoName(directory: string): string | null { + return getGitRepoInfo(directory).name; +} - // Otherwise, auto-generate based on containerTagPrefix - const email = getGitEmail(); - if (email) { - return `${CONFIG.containerTagPrefix}_user_${sha256(email)}`; +function loadClaudeProjectConfig(directory: string): { + personalContainerTag?: string; + repoContainerTag?: string; +} | null { + try { + const configPath = join( + getProjectBasePath(directory), + ".claude", + ".supermemory-claude", + "config.json", + ); + if (!existsSync(configPath)) return null; + return JSON.parse(readFileSync(configPath, "utf-8")) as { + personalContainerTag?: string; + repoContainerTag?: string; + }; + } catch { + return null; } - const fallback = process.env.USER || process.env.USERNAME || "anonymous"; - return `${CONFIG.containerTagPrefix}_user_${sha256(fallback)}`; } -export function getProjectTag(directory: string): string { - // If projectContainerTag is explicitly set, use it - if (CONFIG.projectContainerTag) { - return CONFIG.projectContainerTag; +function loadCodexConfig(): { + containerTagPrefix?: string; + userContainerTag?: string; + projectContainerTag?: string; +} | null { + try { + const configPath = join(homedir(), ".codex", "supermemory.json"); + if (!existsSync(configPath)) return null; + return JSON.parse(readFileSync(configPath, "utf-8")) as { + containerTagPrefix?: string; + userContainerTag?: string; + projectContainerTag?: string; + }; + } catch { + return null; } +} + +export function sanitizeRepoName(name: string): string { + const sanitized = name + .toLowerCase() + .replace(/[^a-z0-9]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); + return sanitized.slice(0, 95).replace(/_+$/g, "") || "unknown"; +} + +export function getProjectIdentity(directory: string): string { + const basePath = getProjectBasePath(directory); + const { normalizedRemote } = getGitRepoInfo(basePath); + const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; + let localIdentity = basePath; + try { + localIdentity = realpathSync.native(basePath); + } catch {} + return sha256( + !isolateWorktrees && normalizedRemote + ? normalizedRemote + : `path:${localIdentity}`, + ); +} + +export function getGeneratedProjectTag(directory: string): string { + const basePath = getProjectBasePath(directory); + const repoName = getGitRepoName(basePath) || basename(basePath) || "unknown"; + const shortName = sanitizeRepoName(repoName).slice(0, 72).replace(/_+$/g, ""); + return `repo_${shortName || "unknown"}__${getProjectIdentity(directory)}`; +} + +export function getLegacyGeneratedProjectTag(directory: string): string { + const basePath = getProjectBasePath(directory); + const repoName = getGitRepoName(basePath) || basename(basePath) || "unknown"; + return `repo_${sanitizeRepoName(repoName)}`; +} + +export function getProjectTag(directory: string): string { + return ( + loadClaudeProjectConfig(directory)?.repoContainerTag || + CONFIG.projectContainerTag || + loadCodexConfig()?.projectContainerTag || + getGeneratedProjectTag(directory) + ); +} + +export function getUserTag(directory = process.cwd()): string { + return getProjectTag(directory); +} + +export function getProjectName(directory: string): string { + const basePath = getProjectBasePath(directory); + return getGitRepoName(basePath) || basename(basePath) || "unknown"; +} + +function getLegacyClaudePersonalTags(directory: string): string[] { + const projectHash = sha256(getProjectBasePath(directory)); + const claudeConfig = loadClaudeProjectConfig(directory); + return uniqueTags([ + claudeConfig?.personalContainerTag, + `user_project_${projectHash}`, + `claudecode_project_${projectHash}`, + ]); +} + +function getLegacyCodexUserTags(directory: string): string[] { + const config = loadCodexConfig(); + const identity = + getGitEmail(directory) || + process.env.USER || + process.env.USERNAME || + hostname(); + const hash = sha256(identity); + return uniqueTags([ + config?.userContainerTag, + `${config?.containerTagPrefix || "codex"}_user_${hash}`, + `codex_user_${hash}`, + ]); +} + +function getLegacyCodexProjectTags(directory: string): string[] { + const config = loadCodexConfig(); + const projectHash = sha256(getProjectBasePath(directory)); + return uniqueTags([ + config?.projectContainerTag, + `${config?.containerTagPrefix || "codex"}_project_${projectHash}`, + `codex_project_${projectHash}`, + ]); +} + +export function getLegacyOpenCodeUserTags(directory: string): string[] { + const identity = + getGitEmail(directory) || + process.env.USER || + process.env.USERNAME || + "anonymous"; + const hash = sha256(identity); + return uniqueTags([ + CONFIG.userContainerTag, + `${CONFIG.containerTagPrefix}_user_${hash}`, + `opencode_user_${hash}`, + ]); +} + +export function getLegacyOpenCodeProjectTags(directory: string): string[] { + const directoryHashes = uniqueTags([ + sha256(directory), + sha256(resolve(directory)), + sha256(getProjectBasePath(directory)), + ]); + return uniqueTags([ + CONFIG.projectContainerTag, + ...directoryHashes.flatMap((hash) => [ + `${CONFIG.containerTagPrefix}_project_${hash}`, + `opencode_project_${hash}`, + ]), + ]); +} + +function uniqueTags(tags: Array): string[] { + return [ + ...new Set( + tags.filter( + (tag): tag is string => + typeof tag === "string" && tag.trim().length > 0, + ), + ), + ]; +} + +export function getPersonalReadTags(directory: string): string[] { + return uniqueTags([ + getProjectTag(directory), + getGeneratedProjectTag(directory), + ...getLegacyClaudePersonalTags(directory), + ...getLegacyCodexUserTags(directory), + ...getLegacyOpenCodeUserTags(directory), + ]); +} + +export function getProjectReadTags(directory: string): string[] { + return uniqueTags([ + getProjectTag(directory), + getGeneratedProjectTag(directory), + getLegacyGeneratedProjectTag(directory), + ...getLegacyCodexProjectTags(directory), + ...getLegacyOpenCodeProjectTags(directory), + ]); +} + +export function getAllReadTags(directory: string): string[] { + return uniqueTags([ + ...getPersonalReadTags(directory), + ...getProjectReadTags(directory), + ]); +} - // Otherwise, auto-generate based on containerTagPrefix - return `${CONFIG.containerTagPrefix}_project_${sha256(directory)}`; +export interface ResolvedTags { + canonical: string; + user: string; + project: string; + projectId: string; + projectName: string; + personalReads: string[]; + projectReads: string[]; + allReads: string[]; } -export function getTags(directory: string): { user: string; project: string } { +export function getTags(directory: string): ResolvedTags { + const canonical = getProjectTag(directory); return { - user: getUserTag(), - project: getProjectTag(directory), + canonical, + user: canonical, + project: canonical, + projectId: getProjectIdentity(directory), + projectName: getProjectName(directory), + personalReads: getPersonalReadTags(directory), + projectReads: getProjectReadTags(directory), + allReads: getAllReadTags(directory), }; }