Skip to content

feat(mcp): auto-import Claude memories, upgrade server instructions#107

Merged
wcatz merged 4 commits intomainfrom
fix/duplicate-project-merge
Mar 22, 2026
Merged

feat(mcp): auto-import Claude memories, upgrade server instructions#107
wcatz merged 4 commits intomainfrom
fix/duplicate-project-merge

Conversation

@wcatz
Copy link
Copy Markdown
Owner

@wcatz wcatz commented Mar 22, 2026

Summary

  • Claude memory auto-import: new internal/claudeimport/ package reads Claude Code's ~/.claude/projects/*/memory/*.md files on first project contact. Read-only — never modifies Claude's files. Hooks into both CLI cold-start (orchestrator) and MCP first-contact (ghost_project_context).
  • Comprehensive MCP instructions: upgraded ServerOptions.Instructions from 15 lines to full guidance covering proactive save triggers, category examples, importance scale, cross-project workflows. Any Ghost user gets the full experience without manual CLAUDE.md edits.
  • Improved tool descriptions: ghost_memory_save, ghost_decision_record, ghost_task_create now encourage proactive usage.
  • ghost_list_projects tool: new MCP tool for session-start project discovery.
  • Duplicate project auto-merge: when CLI encounters a project previously created by MCP with name-only ID, auto-merges all child records.
  • README: updated MCP tools table (12→13), documented claudeimport in architecture, added auto-import to feature list.

Test plan

  • go vet ./... clean
  • go test ./... all pass (19 new tests in claudeimport)
  • Binary builds (CGO_ENABLED=0)
  • VSCode extension compiles
  • CI checks pass
  • CodeRabbit review

Summary by CodeRabbit

  • New Features

    • Project listing tool with per-project memory counts and a clear "no projects" message.
    • Automatic import of Claude Code memories on first project contact.
    • Deterministic short-hash IDs for project contexts.
  • Bug Fixes

    • Automatic detection and safe merging of duplicate projects so memories consolidate.
  • Docs

    • Updated user-facing MCP/server guidance and workflow text.
  • Tests

    • New tests for project merging, Claude import, and hash-ID behavior.

…CP tool

MCP and REST API create projects with name-as-ID while the orchestrator
uses sha256(absPath)[:12], causing duplicate project rows and fragmented
memories. EnsureProject now auto-detects and merges same-name duplicates
when called with a real filesystem path.

- Extract project.HashID() for reusable hash ID computation
- Add Store.MergeProject() to reassign all child records in a transaction
- Auto-merge in EnsureProject when abs-path project meets name-as-ID dup
- Add ghost_list_projects MCP tool for project discovery
- Expand MCP Instructions field with workflow, categories, importance guide
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 22, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Added Claude Code auto-import on first project contact, a new MCP tool ghost_list_projects, expanded MCP instructions, project auto-merge in EnsureProject with a transactional MergeProject, and extracted HashID for deterministic 12-hex project IDs.

Changes

Cohort / File(s) Summary
MCP server & docs
internal/mcpserver/mcpserver.go, README.md
Expanded MCP instructions and tool descriptions; added ghost_list_projects; on first contact, after resolving project_id the server checks memory count and invokes claudeimport.Import when count==0.
Claude import package & tests
internal/claudeimport/...
internal/claudeimport/import.go, internal/claudeimport/import_test.go
New package to discover and import Claude Code Markdown memories (frontmatter parsing, category/importance mapping, truncation, tagging, idempotent Upsert); exposes Import(...); comprehensive unit/integration tests.
Store: merge & EnsureProject changes
internal/memory/store.go, internal/memory/store_test.go
EnsureProject auto-merge path for absolute paths; added MergeProject(ctx, oldID, newID) and internal mergeProjectLocked performing transactional reassignment of project_id across tables and deletion of old project; tests for merge, no-op merge, and auto-merge.
Project context helper & tests
internal/project/context.go, internal/project/context_test.go
Added HashID(input string) string returning deterministic 12-hex IDs; Detect now uses HashID; added TestHashID.
Provider interface & test mocks
internal/provider/provider.go, internal/server/server_test.go
Extended MemoryStore interface with MergeProject(ctx, oldID, newID) error; updated mockStore test double to implement new method.
Orchestrator adjustments
internal/orchestrator/orchestrator.go
StartSession cold-start now attempts claudeimport.Import in background and only falls back to onboarding when import yields zero memories and client present.

Sequence Diagram

sequenceDiagram
    participant Client
    participant MCPServer
    participant EnsureProject
    participant Store
    participant ClaudeImport
    participant Database

    Client->>MCPServer: request project context / first contact
    MCPServer->>EnsureProject: resolve project_id (HashID / name resolution)
    EnsureProject->>Store: CountMemories(projectID)
    alt count == 0
        EnsureProject->>ClaudeImport: Import(ctx, store, projectID, projectPath)
        ClaudeImport-->>Store: Upsert imported memories (tags: claude-code, auto-import)
        ClaudeImport-->>EnsureProject: return imported count
        alt imported == 0 and LLM client present
            EnsureProject->>MCPServer: spawn onboardProject(...)
        end
    end
    alt duplicate detected (abs path vs name)
        EnsureProject->>Store: MergeProject(oldID, newID)
        Store->>Database: BEGIN
        Database->>Database: UPDATE project_id in memories, conversations, tasks, decisions, token_usage, audit_log
        Database->>Database: DELETE ghost_state[oldID], DELETE projects[oldID]
        Database->>Store: COMMIT
        Store-->>EnsureProject: merge success
    end
    EnsureProject-->>Client: project ensured / context returned
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through hashes and merged two homes,

Pulled Claude’s memories from dusty tomes.
A list now shows projects, counts neat and bright,
I stitched duplicates, then saved them just right. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the primary changes: adding auto-import of Claude memories and upgrading MCP server instructions with comprehensive documentation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/duplicate-project-merge

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/memory/store.go (1)

117-121: Platform-specific path detection in auto-merge query.

The condition path NOT LIKE '/%' only detects non-absolute paths on Unix-like systems. On Windows, absolute paths start with a drive letter (e.g., C:\), so this heuristic would incorrectly treat Windows absolute paths as MCP-created paths.

If Windows support is needed, consider using a more robust check or storing a flag indicating the path type.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/memory/store.go` around lines 117 - 121, The SQL heuristic `path NOT
LIKE '/%'` in the QueryRowContext call incorrectly treats Windows absolute paths
as non-absolute; update the logic to be platform-agnostic by recording path
metadata instead of relying on string patterns: add a boolean column (e.g.,
projects.is_mcp_created or projects.is_absolute) and set it when
inserting/updating projects, then change the query in the function that calls
s.db.QueryRowContext (the query that scans into dupID) to filter on that boolean
flag instead of `path NOT LIKE '/%'`; ensure all insert/update code paths that
create project rows (where name, id, path are set) also set the new flag.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/mcpserver/mcpserver.go`:
- Around line 569-572: In the loop that builds the project list (the for _, p :=
range projects block) replace the sb.WriteString(fmt.Sprintf(...)) pattern with
a direct fmt.Fprintf call to write to sb (fmt.Fprintf(sb, "- **%s** (id: `%s`,
path: `%s`) — %d memories\n", p.Name, p.ID, p.Path, count)). Use sb as the
io.Writer and handle/ignore the returned (int, error) per project (e.g., assign
to _, _ or check and log) to satisfy staticcheck QF1012 and avoid the
unnecessary intermediate allocation; keep the call site referencing sb and
s.store.CountMemories unchanged.

---

Nitpick comments:
In `@internal/memory/store.go`:
- Around line 117-121: The SQL heuristic `path NOT LIKE '/%'` in the
QueryRowContext call incorrectly treats Windows absolute paths as non-absolute;
update the logic to be platform-agnostic by recording path metadata instead of
relying on string patterns: add a boolean column (e.g., projects.is_mcp_created
or projects.is_absolute) and set it when inserting/updating projects, then
change the query in the function that calls s.db.QueryRowContext (the query that
scans into dupID) to filter on that boolean flag instead of `path NOT LIKE
'/%'`; ensure all insert/update code paths that create project rows (where name,
id, path are set) also set the new flag.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7688f3ff-2bc8-43d5-a35a-f9087f5779c9

📥 Commits

Reviewing files that changed from the base of the PR and between 79e47ff and 99274e8.

📒 Files selected for processing (7)
  • internal/mcpserver/mcpserver.go
  • internal/memory/store.go
  • internal/memory/store_test.go
  • internal/project/context.go
  • internal/project/context_test.go
  • internal/provider/provider.go
  • internal/server/server_test.go

Comment thread internal/mcpserver/mcpserver.go
wcatz added 2 commits March 22, 2026 06:20
Fixes staticcheck QF1012 in ghost_list_projects tool.
Add internal/claudeimport package that reads Claude Code's auto-memory
files (~/.claude/projects/*/memory/*.md) on first project contact.
Read-only — never modifies Claude's files. Parses YAML frontmatter,
maps types to Ghost categories, uses upsert for dedup.

Hook into orchestrator cold-start (CLI path) and ghost_project_context
handler (MCP path). If Claude memories exist, import them before
falling back to Haiku-based onboarding.

Upgrade MCP ServerOptions.Instructions from 15 lines to comprehensive
guidance covering proactive save triggers, category examples, importance
guidelines, and cross-project workflows. Update 3 tool descriptions
to encourage proactive memory saving.

Update README: add ghost_list_projects to MCP tools table (12→13),
document claudeimport package in architecture, note auto-import in
feature list.
@wcatz wcatz changed the title fix(memory): auto-merge duplicate projects, add ghost_list_projects feat(mcp): auto-import Claude memories, upgrade server instructions Mar 22, 2026
}
encoded := encodeProjectPath(projectPath)
dir := filepath.Join(home, ".claude", "projects", encoded, "memory")
if info, err := os.Stat(dir); err != nil || !info.IsDir() {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI about 2 months ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

// Ghost category, and importance. Returns skip=true for files that should
// not be imported.
func ParseMemoryFile(path string) (content, category string, importance float32, skip bool, err error) {
data, err := os.ReadFile(path)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI about 2 months ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.


// importFromDir imports all Claude Code memories from a specific directory.
func importFromDir(ctx context.Context, store provider.MemoryStore, projectID, dir string, logger *slog.Logger) (int, error) {
entries, err := os.ReadDir(dir)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI about 2 months ago

In general, to fix uncontrolled path usage you ensure that any path derived from user input is constrained to a safe root and validated as such before using it with filesystem APIs. This usually means joining under a fixed base directory, resolving to an absolute path, and confirming that the result still lies under that base (for example, via filepath.Abs + prefix check), or normalizing components and rejecting unwanted patterns.

In this codebase, ClaudeMemoryDir already constructs dir as filepath.Join(home, ".claude", "projects", encoded, "memory"). To make the path use explicitly safe and clear to both CodeQL and future maintainers, we can add a normalization / containment check there (or just before os.ReadDir). The safest minimal change—without altering observable behavior—is to normalize the constructed dir to an absolute path, build a canonical base ($HOME/.claude/projects) the same way, and ensure that dir has base as a prefix. If it fails the check, we return "" so no import or directory read occurs. This keeps behavior the same for valid inputs but prevents any crafted value from escaping the intended Claude projects directory and should address the CodeQL concern.

Concretely:

  • Edit ClaudeMemoryDir in internal/claudeimport/import.go.
  • After constructing dir, compute:
    • base := filepath.Join(home, ".claude", "projects")
    • baseAbs := filepath.Clean(base)
    • dirAbs := filepath.Clean(dir)
  • If dirAbs is not equal to baseAbs and does not have baseAbs+string(os.PathSeparator) as a prefix, return "".
  • Keep the existing os.Stat/IsDir check afterwards.
    This uses only existing imports (os, filepath) and doesn’t require changes elsewhere.
Suggested changeset 1
internal/claudeimport/import.go

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/internal/claudeimport/import.go b/internal/claudeimport/import.go
--- a/internal/claudeimport/import.go
+++ b/internal/claudeimport/import.go
@@ -57,11 +57,21 @@
 		return ""
 	}
 	encoded := encodeProjectPath(projectPath)
-	dir := filepath.Join(home, ".claude", "projects", encoded, "memory")
-	if info, err := os.Stat(dir); err != nil || !info.IsDir() {
+	// Construct the expected memory directory under the fixed Claude projects root.
+	base := filepath.Join(home, ".claude", "projects")
+	dir := filepath.Join(base, encoded, "memory")
+
+	// Normalize and ensure dir is within the Claude projects base directory.
+	baseAbs := filepath.Clean(base)
+	dirAbs := filepath.Clean(dir)
+	if dirAbs != baseAbs && !strings.HasPrefix(dirAbs, baseAbs+string(os.PathSeparator)) {
 		return ""
 	}
-	return dir
+
+	if info, err := os.Stat(dirAbs); err != nil || !info.IsDir() {
+		return ""
+	}
+	return dirAbs
 }
 
 // skipFile returns true if the filename should not be imported.
EOF
@@ -57,11 +57,21 @@
return ""
}
encoded := encodeProjectPath(projectPath)
dir := filepath.Join(home, ".claude", "projects", encoded, "memory")
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
// Construct the expected memory directory under the fixed Claude projects root.
base := filepath.Join(home, ".claude", "projects")
dir := filepath.Join(base, encoded, "memory")

// Normalize and ensure dir is within the Claude projects base directory.
baseAbs := filepath.Clean(base)
dirAbs := filepath.Clean(dir)
if dirAbs != baseAbs && !strings.HasPrefix(dirAbs, baseAbs+string(os.PathSeparator)) {
return ""
}
return dir

if info, err := os.Stat(dirAbs); err != nil || !info.IsDir() {
return ""
}
return dirAbs
}

// skipFile returns true if the filename should not be imported.
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/claudeimport/import_test.go`:
- Around line 289-293: Replace the unchecked defer db.Close() with a checked
cleanup: call t.Cleanup(func() { if err := db.Close(); err != nil {
t.Fatalf("closing memory DB: %v", err) } }) so any error returned by
memory.OpenDB's Close is reported; update the block where
memory.OpenDB(":memory:") is called and remove the plain defer db.Close().

In `@internal/claudeimport/import.go`:
- Around line 46-60: encodeProjectPath and ClaudeMemoryDir fail for native
Windows paths because backslashes and drive letters make the joined path rooted;
normalize the projectPath in ClaudeMemoryDir (or inside encodeProjectPath) by
calling filepath.Clean and filepath.ToSlash, strip any Windows volume name (use
filepath.VolumeName to detect and strings.TrimPrefix to remove it), then replace
remaining "/" with "-" (and TrimLeft any leading "-" so a leading slash or
removed drive doesn't produce a leading dash). Update the encodeProjectPath
and/or ClaudeMemoryDir flow to operate on the normalized path so C:\work\ghost
becomes "C-work-ghost" or "work-ghost" consistently and the final
filepath.Join(home, ".claude", "projects", encoded, "memory") is not treated as
rooted on Windows.
- Around line 138-149: parseFrontmatter currently only recognizes LF delimiters
("---\n") so CRLF frontmatter ("---\r\n") is treated as body; update
parseFrontmatter to accept both LF and CRLF boundaries by checking for both
starting delimiters (e.g., "---\n" and "---\r\n") and searching for matching
closing delimiters ("\n---" and "\r\n---") when computing end and slicing body;
ensure fm extraction and body = raw[...] calculations account for the chosen
delimiter length (2 vs 4 chars) so indices remain correct and the returned
name/description/fileType parsing still uses the cleaned fm.

In `@internal/mcpserver/mcpserver.go`:
- Around line 271-277: The code swallows the error returned by
claudeimport.Import which hides import failures; update the clause in
mcpserver.go (around CountMemories/ListProjects logic) to capture the returned
error from claudeimport.Import(ctx, s.store, args.ProjectID, p.Path, s.logger)
and handle it instead of discarding it — at minimum log it via s.logger (or
return it to the caller) so the errcheck violation is resolved and first-contact
import failures are visible; ensure you modify the block that calls
CountMemories, ListProjects and claudeimport.Import to check the import error
and act accordingly.
- Around line 86-87: ghost_memory_save is resolving name-based args.ProjectID to
the hash ID then calling EnsureProject(path=args.ProjectID), which overwrites
the existing projects.path with the hash; update ghost_memory_save so that if
the project already exists (lookup via the resolved ID or EnsureProject result
indicates existing), you do not replace the stored Path/Name—either skip the
EnsureProject call or call EnsureProject with the original filesystem path/name
preserved and avoid updating projects.path when a row already exists; ensure
this check uses the same identifiers used elsewhere (args.ProjectID, the
resolved project hash, and the EnsureProject return) to detect existing projects
and preserve projects.path so ghost_list_projects and import/merge gates keep
working.

In `@internal/orchestrator/orchestrator.go`:
- Around line 84-96: The goroutine uses the initial CountMemories result to
decide onboarding, which can race with other imports; after running
claudeimport.Import (or if importErr != nil), re-query the store by calling
o.store.CountMemories(ctx, projCtx.ID) inside the goroutine and only call
onboardProject(bgCtx, o.client, o.store, projCtx, o.logger) if that fresh count
is still 0 and o.client != nil; also handle and log any count error (treat
non-nil error as conservatively non-zero to avoid onboarding duplication).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7b59b24d-e107-42ba-b5a7-d32aa8922087

📥 Commits

Reviewing files that changed from the base of the PR and between d9bc8a1 and 26fd93c.

📒 Files selected for processing (5)
  • README.md
  • internal/claudeimport/import.go
  • internal/claudeimport/import_test.go
  • internal/mcpserver/mcpserver.go
  • internal/orchestrator/orchestrator.go

Comment thread internal/claudeimport/import_test.go Outdated
Comment on lines +289 to +293
db, err := memory.OpenDB(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Check db.Close() in test cleanup.

CI is failing on Line 293 because the db.Close() error is ignored. Wrap it in t.Cleanup or a checked defer so cleanup failures are still reported.

🔧 Suggested fix
 	db, err := memory.OpenDB(":memory:")
 	if err != nil {
 		t.Fatal(err)
 	}
-	defer db.Close()
+	t.Cleanup(func() {
+		if err := db.Close(); err != nil {
+			t.Fatalf("close db: %v", err)
+		}
+	})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
db, err := memory.OpenDB(":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db, err := memory.OpenDB(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Fatalf("close db: %v", err)
}
})
🧰 Tools
🪛 GitHub Actions: ci

[error] 293-293: golangci-lint (errcheck): Error return value of db.Close is not checked.

🪛 GitHub Check: build-and-test

[failure] 293-293:
Error return value of db.Close is not checked (errcheck)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/claudeimport/import_test.go` around lines 289 - 293, Replace the
unchecked defer db.Close() with a checked cleanup: call t.Cleanup(func() { if
err := db.Close(); err != nil { t.Fatalf("closing memory DB: %v", err) } }) so
any error returned by memory.OpenDB's Close is reported; update the block where
memory.OpenDB(":memory:") is called and remove the plain defer db.Close().

Comment on lines +46 to +60
// encodeProjectPath converts an absolute path to Claude's directory name format.
// E.g., "/home/wayne/git/ghost" → "-home-wayne-git-ghost".
func encodeProjectPath(projectPath string) string {
return strings.ReplaceAll(projectPath, "/", "-")
}

// ClaudeMemoryDir returns the path to Claude Code's memory directory for the
// given absolute project path. Returns "" if the directory does not exist.
func ClaudeMemoryDir(projectPath string) string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
encoded := encodeProjectPath(projectPath)
dir := filepath.Join(home, ".claude", "projects", encoded, "memory")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize Windows paths before encoding Claude's project directory.

encodeProjectPath only rewrites /. For a native Windows path like C:\work\ghost, the encoded value still contains a drive prefix and backslashes, so filepath.Join treats it as rooted and ClaudeMemoryDir no longer points under ~/.claude/projects/.../memory. That makes the new auto-import path effectively POSIX-only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/claudeimport/import.go` around lines 46 - 60, encodeProjectPath and
ClaudeMemoryDir fail for native Windows paths because backslashes and drive
letters make the joined path rooted; normalize the projectPath in
ClaudeMemoryDir (or inside encodeProjectPath) by calling filepath.Clean and
filepath.ToSlash, strip any Windows volume name (use filepath.VolumeName to
detect and strings.TrimPrefix to remove it), then replace remaining "/" with "-"
(and TrimLeft any leading "-" so a leading slash or removed drive doesn't
produce a leading dash). Update the encodeProjectPath and/or ClaudeMemoryDir
flow to operate on the normalized path so C:\work\ghost becomes "C-work-ghost"
or "work-ghost" consistently and the final filepath.Join(home, ".claude",
"projects", encoded, "memory") is not treated as rooted on Windows.

Comment on lines +138 to +149
func parseFrontmatter(raw string) (name, description, fileType, body string) {
if !strings.HasPrefix(raw, "---\n") {
return "", "", "", raw
}

end := strings.Index(raw[4:], "\n---")
if end < 0 {
return "", "", "", raw
}

fm := raw[4 : 4+end]
body = raw[4+end+4:] // skip closing "---\n"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

CRLF frontmatter is parsed as plain markdown.

parseFrontmatter only recognizes ---\n delimiters. Files written with CRLF (---\r\n) fall through the “no frontmatter” path, so the YAML header ends up in the stored content and the Claude type never maps to the intended Ghost category/importance.

🔧 Suggested fix
 func parseFrontmatter(raw string) (name, description, fileType, body string) {
+	raw = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(raw)
 	if !strings.HasPrefix(raw, "---\n") {
 		return "", "", "", raw
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func parseFrontmatter(raw string) (name, description, fileType, body string) {
if !strings.HasPrefix(raw, "---\n") {
return "", "", "", raw
}
end := strings.Index(raw[4:], "\n---")
if end < 0 {
return "", "", "", raw
}
fm := raw[4 : 4+end]
body = raw[4+end+4:] // skip closing "---\n"
func parseFrontmatter(raw string) (name, description, fileType, body string) {
raw = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(raw)
if !strings.HasPrefix(raw, "---\n") {
return "", "", "", raw
}
end := strings.Index(raw[4:], "\n---")
if end < 0 {
return "", "", "", raw
}
fm := raw[4 : 4+end]
body = raw[4+end+4:] // skip closing "---\n"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/claudeimport/import.go` around lines 138 - 149, parseFrontmatter
currently only recognizes LF delimiters ("---\n") so CRLF frontmatter
("---\r\n") is treated as body; update parseFrontmatter to accept both LF and
CRLF boundaries by checking for both starting delimiters (e.g., "---\n" and
"---\r\n") and searching for matching closing delimiters ("\n---" and "\r\n---")
when computing end and slicing body; ensure fm extraction and body = raw[...]
calculations account for the chosen delimiter length (2 vs 4 chars) so indices
remain correct and the returned name/description/fileType parsing still uses the
cleaned fm.

Comment on lines +86 to +87
## Project IDs
Pass the project name (e.g. "ghost", "roller") as project_id. Ghost resolves names to internal IDs automatically.`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Name-based project_id saves currently overwrite the real project path.

The new instructions tell MCP clients to pass project names, but ghost_memory_save resolves that name to the hash ID and then calls EnsureProject with path=args.ProjectID. On Line 225 that updates an existing row's absolute projects.path to the hash, so ghost_list_projects loses the filesystem path and the absolute-path gates for Claude import / auto-merge stop working until another TUI session repairs it. Please preserve the stored Path/Name when the project already exists, or skip EnsureProject in that case.

Also applies to: 199-199

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/mcpserver/mcpserver.go` around lines 86 - 87, ghost_memory_save is
resolving name-based args.ProjectID to the hash ID then calling
EnsureProject(path=args.ProjectID), which overwrites the existing projects.path
with the hash; update ghost_memory_save so that if the project already exists
(lookup via the resolved ID or EnsureProject result indicates existing), you do
not replace the stored Path/Name—either skip the EnsureProject call or call
EnsureProject with the original filesystem path/name preserved and avoid
updating projects.path when a row already exists; ensure this check uses the
same identifiers used elsewhere (args.ProjectID, the resolved project hash, and
the EnsureProject return) to detect existing projects and preserve projects.path
so ghost_list_projects and import/merge gates keep working.

Comment thread internal/mcpserver/mcpserver.go Outdated
Comment on lines +271 to +277
// First-contact import: if project has zero memories, try importing
// from Claude Code's auto-memory files (read-only, one-time).
if cnt, cntErr := s.store.CountMemories(ctx, args.ProjectID); cntErr == nil && cnt == 0 {
if projects, lErr := s.store.ListProjects(ctx); lErr == nil {
for _, p := range projects {
if p.ID == args.ProjectID && filepath.IsAbs(p.Path) {
claudeimport.Import(ctx, s.store, args.ProjectID, p.Path, s.logger)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not drop the Claude import error here.

CI is already failing on this errcheck violation, and swallowing the error makes a broken first-contact import look like a legitimate “no memories found” case to the client.

🔧 Suggested fix
 				for _, p := range projects {
 					if p.ID == args.ProjectID && filepath.IsAbs(p.Path) {
-						claudeimport.Import(ctx, s.store, args.ProjectID, p.Path, s.logger)
+						if _, err := claudeimport.Import(ctx, s.store, args.ProjectID, p.Path, s.logger); err != nil {
+							s.logger.Warn("claude memory import failed", "project", p.Name, "path", p.Path, "error", err)
+						}
 						break
 					}
 				}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// First-contact import: if project has zero memories, try importing
// from Claude Code's auto-memory files (read-only, one-time).
if cnt, cntErr := s.store.CountMemories(ctx, args.ProjectID); cntErr == nil && cnt == 0 {
if projects, lErr := s.store.ListProjects(ctx); lErr == nil {
for _, p := range projects {
if p.ID == args.ProjectID && filepath.IsAbs(p.Path) {
claudeimport.Import(ctx, s.store, args.ProjectID, p.Path, s.logger)
// First-contact import: if project has zero memories, try importing
// from Claude Code's auto-memory files (read-only, one-time).
if cnt, cntErr := s.store.CountMemories(ctx, args.ProjectID); cntErr == nil && cnt == 0 {
if projects, lErr := s.store.ListProjects(ctx); lErr == nil {
for _, p := range projects {
if p.ID == args.ProjectID && filepath.IsAbs(p.Path) {
if _, err := claudeimport.Import(ctx, s.store, args.ProjectID, p.Path, s.logger); err != nil {
s.logger.Warn("claude memory import failed", "project", p.Name, "path", p.Path, "error", err)
}
break
}
}
🧰 Tools
🪛 GitHub Check: build-and-test

[failure] 277-277:
Error return value of claudeimport.Import is not checked (errcheck)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/mcpserver/mcpserver.go` around lines 271 - 277, The code swallows
the error returned by claudeimport.Import which hides import failures; update
the clause in mcpserver.go (around CountMemories/ListProjects logic) to capture
the returned error from claudeimport.Import(ctx, s.store, args.ProjectID,
p.Path, s.logger) and handle it instead of discarding it — at minimum log it via
s.logger (or return it to the caller) so the errcheck violation is resolved and
first-contact import failures are visible; ensure you modify the block that
calls CountMemories, ListProjects and claudeimport.Import to check the import
error and act accordingly.

Comment on lines +84 to +96
// Cold-start: import Claude Code memories first, fall back to LLM onboarding.
count, err := o.store.CountMemories(ctx, projCtx.ID)
if err == nil && count == 0 {
go onboardProject(context.Background(), o.client, o.store, projCtx, o.logger)
go func() {
bgCtx := context.Background()
imported, importErr := claudeimport.Import(bgCtx, o.store, projCtx.ID, projCtx.Path, o.logger)
if importErr != nil {
o.logger.Warn("claude memory import failed", "project", projCtx.Name, "error", importErr)
}
if imported == 0 && o.client != nil {
onboardProject(bgCtx, o.client, o.store, projCtx, o.logger)
}
}()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Re-check the memory count before calling the onboarding fallback.

The zero-count decision is made on Line 85, but the fallback runs later in a goroutine. Another first-contact path can import/save memories between Line 85 and Line 93, and this still calls onboardProject, which burns an extra LLM call and writes a second batch of onboarding memories.

🔧 Suggested fix
 			if importErr != nil {
 				o.logger.Warn("claude memory import failed", "project", projCtx.Name, "error", importErr)
 			}
 			if imported == 0 && o.client != nil {
-				onboardProject(bgCtx, o.client, o.store, projCtx, o.logger)
+				if count, err := o.store.CountMemories(bgCtx, projCtx.ID); err == nil && count == 0 {
+					onboardProject(bgCtx, o.client, o.store, projCtx, o.logger)
+				}
 			}
 		}()
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Cold-start: import Claude Code memories first, fall back to LLM onboarding.
count, err := o.store.CountMemories(ctx, projCtx.ID)
if err == nil && count == 0 {
go onboardProject(context.Background(), o.client, o.store, projCtx, o.logger)
go func() {
bgCtx := context.Background()
imported, importErr := claudeimport.Import(bgCtx, o.store, projCtx.ID, projCtx.Path, o.logger)
if importErr != nil {
o.logger.Warn("claude memory import failed", "project", projCtx.Name, "error", importErr)
}
if imported == 0 && o.client != nil {
onboardProject(bgCtx, o.client, o.store, projCtx, o.logger)
}
}()
// Cold-start: import Claude Code memories first, fall back to LLM onboarding.
count, err := o.store.CountMemories(ctx, projCtx.ID)
if err == nil && count == 0 {
go func() {
bgCtx := context.Background()
imported, importErr := claudeimport.Import(bgCtx, o.store, projCtx.ID, projCtx.Path, o.logger)
if importErr != nil {
o.logger.Warn("claude memory import failed", "project", projCtx.Name, "error", importErr)
}
if imported == 0 && o.client != nil {
if count, err := o.store.CountMemories(bgCtx, projCtx.ID); err == nil && count == 0 {
onboardProject(bgCtx, o.client, o.store, projCtx, o.logger)
}
}
}()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/orchestrator/orchestrator.go` around lines 84 - 96, The goroutine
uses the initial CountMemories result to decide onboarding, which can race with
other imports; after running claudeimport.Import (or if importErr != nil),
re-query the store by calling o.store.CountMemories(ctx, projCtx.ID) inside the
goroutine and only call onboardProject(bgCtx, o.client, o.store, projCtx,
o.logger) if that fresh count is still 0 and o.client != nil; also handle and
log any count error (treat non-nil error as conservatively non-zero to avoid
onboarding duplication).

@wcatz wcatz merged commit 56e99f4 into main Mar 22, 2026
3 of 5 checks passed
@wcatz wcatz deleted the fix/duplicate-project-merge branch March 22, 2026 13:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants