Walkthrough of how memorydetective actually works in practice. What each tool returns, how fixes flow from diagnosis to your codebase, and the architecture decision behind splitting "diagnose" from "edit".
For a quick API reference, see the README.md. For the full changelog, see CHANGELOG.md.
npm install -g memorydetective
memorydetective --version # 1.0.0
# Run analyze on any .memgraph file
memorydetective analyze ~/Desktop/myapp.memgraphWhat you see (terminal output, ANSI-coloured):
┌─ memorydetective analyze ──────────────────────────────────────┐
│ Path: /Users/.../myapp.memgraph
│ Process: MyApp (pid 12345)
│ Bundle: com.example.myapp
└────────────────────────────────────────────────────────────────┘
60,436 leaks (7.89 MB)
4 ROOT CYCLE blocks
Top cycle: Swift._DictionaryStorage<SwiftUI.AnyHashable2, SwiftUI…
chain length: 545 nodes
app-level classes in chain: Closure context, DetailViewModel,
ItemRepositoryImpl, ItemGraphQLDataSource, GraphQLClient
Diagnosis:
60436 leaks; 4 ROOT CYCLE blocks. Largest top-level cycle:
Swift._DictionaryStorage… (chain of 545 nodes). App-level
classes in chains: Closure context, DetailViewModel, …
Then ask the classifier for fix advice:
memorydetective classify ~/Desktop/myapp.memgraphYou see one block per ROOT CYCLE, like:
Root: Swift._DictionaryStorage<SwiftUI.AnyHashable2, SwiftUI…
Match: swiftui.tag-index-projection (high confidence)
Fix hint:
Replace `[weak self]` capture in tap closures with a static
helper, OR weak-capture the coordinator/view-model directly
with `[weak coord = self.coordinator]`. The `.tag()` modifier
on photo carousels is the usual culprit.
Also matched: swiftui.dictstorage-weakbox-cycle,
closure.viewmodel-wrapped-strong,
swiftui.foreach-state-tap
memorydetective analyze ~/Desktop/myapp.memgraph --json | jq .totals
memorydetective classify ~/Desktop/myapp.memgraph --json | jq '.classified[0].primaryMatch'The JSON shape mirrors the MCP tool's response. Same fields, no ANSI colours, ready to pipe into anything.
This is what we built it for: an LLM agent (Claude Code, Claude Desktop, Cursor, Cline, Kiro, …) drives the investigation by chat.
Add to your MCP client config (Claude Code shown):
Open Claude Code in your iOS project and just ask:
Diagnose
~/Desktop/myapp.memgraphand find where to fix in this codebase.
Claude orchestrates the full flow (see section 3).
classifyCycle ships with a built-in catalog of 34 common iOS retain-cycle patterns. Each pattern returns:
- a textual
fixHint(one-line plain-English direction) - a
staticAnalysisHint(which SwiftLint rule complements the runtime evidence. Or an explicit gap notice) - a
fixTemplate(Swift before/after code snippet. New in v1.7) the agent can adapt directly
Patterns are grouped below by the framework / source they target.
| Pattern ID | When it matches | Fix hint (summary) |
|---|---|---|
swiftui.tag-index-projection |
TagIndexProjection<Int> appears in chain (.tag() modifier capturing self) |
Replace [weak self] capture with a static helper, or weak-capture the coordinator/view-model directly. |
swiftui.dictstorage-weakbox-cycle |
Root is _DictionaryStorage<…WeakBox<AnyLocationBase>> |
SwiftUI internal observation graph cycle. Find your app-level types in the chain and break the strong capture there. |
swiftui.foreach-state-tap |
SwiftUI.ForEachState in chain |
ForEachState held by a tap-gesture closure capturing self. Make the tap handler a static function or capture properties weakly. |
closure.viewmodel-wrapped-strong |
__strong edge with _viewModel.wrappedValue in label |
Closure captures _viewModel.wrappedValue strongly. Capture the underlying ObservableObject weakly: [weak vm = _viewModel.wrappedValue]. |
viewcontroller.uinavigationcontroller-host |
UINavigationController + UIHostingController both in chain |
Clear viewControllers = [] in dismantleUIViewController to break the host->VC->host cycle. |
combine.sink-store-self-capture |
AnyCancellable + Closure context |
.sink { self.x = … } keeps self alive through the AnyCancellable that's stored on self. Capture explicitly: .sink { [weak self] in self?.x = … }. |
concurrency.task-without-weak-self |
_Concurrency.Task<…> + Closure context |
Task { } body strongly captures self for the lifetime of the task. Task { [weak self] in guard let self else { return }; … }. |
notificationcenter.observer-strong |
NotificationCenter / NSNotificationCenter + Closure context |
Block-form addObserver(forName:...) keeps the block alive in the center. Use [weak self] in the block, or store the returned NSObjectProtocol and call removeObserver(_:) in deinit. |
| Pattern ID | When it matches | Fix hint (summary) |
|---|---|---|
timer.scheduled-target-strong |
__NSCFTimer / NSTimer in chain |
Timer.scheduledTimer(target:selector:) retains its target. Use the closure form with [weak self] and invalidate() in deinit. |
displaylink.target-strong |
CADisplayLink in chain |
CADisplayLink(target:selector:) retains its target. Wrap with a WeakProxy and invalidate() in deinit. |
gesture.target-strong |
UIGestureRecognizer / UIControl in chain |
addTarget(_:action:) is strong by default. Prefer UIAction (iOS 14+) or removeTarget(...) in deinit. |
kvo.observation-not-invalidated |
NSKeyValueObservation in chain |
obj.observe(\.x) { ... } retains its handler. [weak self] inside, token.invalidate() in deinit. |
urlsession.delegate-strong |
__NSURLSessionLocal / NSURLSession in chain |
URLSession(configuration:delegate:) retains its delegate strongly (Apple-documented). Call invalidateAndCancel() in deinit. |
dispatch.source-event-handler-self |
OS_dispatch_source / DispatchSource in chain |
setEventHandler { ... } retains the closure. Use [weak self] and clear with setEventHandler {} in deinit. |
notificationcenter.observer-not-removed |
NotificationCenter + NSObjectProtocol |
Block-form observer never deregistered. Call removeObserver(_:) in deinit or use the selector form. |
delegate.strong-reference |
Class with Delegate suffix in chain |
var delegate: ...? declared without weak. Mark weak, or refactor to closure-based callback. |
swiftui.envobject-back-reference |
EnvironmentObjectStorage + UIKit interop class in chain |
@EnvironmentObject with strong back-reference to UIView/UIViewController. Wrap UIKit refs in weak box. |
combine.assign-to-self |
Combine.Assign / Subscribers.Assign in chain |
.assign(to: \.x, on: self) retains self. Use .assign(to: &$published) or .sink { [weak self] ... }. |
concurrency.task-mainactor-view |
_Concurrency.Task<…> + SwiftUI View signal |
Task { await self.foo() } inside View.body retains storage. Use .task { ... } modifier or capture properties up front. |
concurrency.asyncstream-continuation-self |
AsyncStream + Closure context in chain |
Continuation retains onTermination/producer closures. [weak self] inside, task.cancel() in deinit/onDisappear. |
webkit.scriptmessage-handler-strong |
WKUserContentController / WKScriptMessageHandler / WKWebView |
add(_:name:) retains the handler. Wrap in WeakScriptMessageHandler proxy or call removeScriptMessageHandler(forName:). |
coordinator.parent-strong-back-reference |
Two *Coordinator nodes in cycle |
Child holds parent without weak. weak var parentCoordinator, removeAll { $0 === finishedChild } on completion. |
rxswift.disposebag-self-cycle |
RxSwift.DisposeBag / RxSwift.AnonymousDisposable in chain |
Subscription retains self if [weak self] is omitted or unbound method ref is passed. Always use [weak self]. |
realm.notificationtoken-retained |
RealmSwift.NotificationToken / RLMNotificationToken |
Results.observe { ... } retains the closure. [weak self] inside, token?.invalidate() in deinit. |
| Pattern ID | When it matches | Fix hint (summary) |
|---|---|---|
coreanimation.animation-delegate-strong |
CABasicAnimation / CAKeyframeAnimation / CASpringAnimation / CAAnimationGroup / CATransition in chain |
CAAnimation.delegate is strong (Apple-documented quirk). Use a WeakProxy delegate or anim.delegate = nil in deinit. |
coreanimation.layer-delegate-cycle |
Custom CALayer subclass (CAShapeLayer / CAGradientLayer / CAEmitterLayer / CAMetalLayer / etc.) in chain without UIView auto-pairing |
Custom layer wired to non-UIView delegate leaks. Wrap in WeakLayerDelegate or clear layer.delegate = nil in deinit. |
coredata.fetchedresultscontroller-delegate |
NSFetchedResultsController / _PFFetchedResultsController in chain |
Apple's historical strong-delegate quirk via the change-tracker. frc.delegate = nil in viewWillDisappear / deinit. |
Sourced from Apple Developer Forums (#736110, #716804, #748042), Swift Forums (#64584, #77257), Donny Wals on the Swift 6.2 Observations API, and the Embrace WKWebView memory-leak writeup.
| Pattern ID | When it matches | Fix hint (summary) |
|---|---|---|
swiftui.observable-state-modal-leak |
_$ObservationRegistrar + sheet/presentation host in chain |
@Observable model passed as @State to a modal leaks. Move the model to @StateObject on the parent and pass via @Bindable, or use .sheet(item:) with a value type. |
swiftui.navigationpath-stored-in-viewmodel |
NavigationPath / NavigationStackStore / AnyHashableStorageBase in chain |
NavigationPath retains every element ever pushed (FB11643551, unfixed). Keep it @State local to the view, or reset with path = NavigationPath() after popToRoot. |
concurrency.async-sequence-on-self |
AsyncSequence / AsyncIteratorProtocol + Task<...> in chain |
for await ... in seq { use(self) } pins self via the iteration context. [weak self] does NOT help. Capture only the values needed before the loop, or task.cancel() in deinit. |
concurrency.notificationcenter-async-observer-task |
NotificationCenter.Notifications (the AsyncSequence form) + Task<...> |
Special case of the above. for await _ in NotificationCenter.default.notifications(named:) never terminates. Same fix discipline. |
swiftui.observations-closure-strong-self |
Observations (the Swift 6.2 API, NOT ObservationRegistrar) + Closure context |
The new non-SwiftUI Observations { } closure retains self like Combine.sink. Use [weak self] inside the closure. |
webkit.wkscriptmessagehandler-bridge |
WKWebView + WKUserContentController + WKScriptMessageHandler (or *Bridge/*Handler class) all in chain |
The 3-link bridge cycle: bridge → webView → contentController → bridge. Wrap the handler in WeakScriptMessageHandler proxy, or removeScriptMessageHandler(forName:) for every name added. Fires alongside the broader v1.4 webkit.scriptmessage-handler-strong pattern when the full bridge shape is present. |
| Pattern ID | When it matches | Fix hint (summary) |
|---|---|---|
swiftdata.modelcontext-actor-cycle |
ModelContext + DefaultSerialModelExecutor (or ModelExecutor) + Actor in chain |
Apple-documented quirk on iOS 17 (FB13844786, fixed in iOS 18 beta 1). Prefer the @ModelActor macro over hand-rolled executors; or hold ModelContext weakly inside a custom executor and re-resolve per operation. |
| Pattern ID | When it matches | Fix hint (summary) |
|---|---|---|
uikit.viewcontroller-retained-after-pop |
A *ViewController subclass is in the heap with no _parentViewController / _presentingViewController edge in the chain |
VC was popped but a closure, Combine sink, NotificationCenter block, or KVO observation is still retaining it. Audit closures captured in viewDidLoad, Task { } blocks that outlive the screen, KVO observations that never invalidate(), and delegate properties declared without weak. |
swiftui.observable-write-on-every-render |
ObservationRegistrar / ObservableObject + SwiftUI view-graph class (ViewGraph, _GraphValue, _ViewList, ViewBodyAccessor, DynamicViewProperty) + Closure context all in chain |
An @Observable is mutated while body is evaluating, scheduling another render. Move the side effect out of body. Use a computed property for derived values, or attach to .onChange(of:) / .task(id:) / .onAppear. |
Confidence tiers: each pattern returns high, medium, or low based on how many specific signals match. If multiple patterns fire on the same cycle, all matches are returned. The highest-confidence one is primaryMatch, the rest are in allMatches.
Static analysis bridge (v1.6+): every classified cycle now carries a staticAnalysisHint field with three sub-fields:
rule: The SwiftLint rule that would have caught this at parse time (weak_self,weak_delegate, etc.), ornullwhen no rule existsurl: Link to the rule docs OR to the open issue tracking the gap (e.g. SwiftLint #776 for@escapingretain cycles)explanation: Plain-English description of the static-vs-runtime relationship
Reinforces the differentiator: memorydetective sees the runtime evidence linters miss. Examples:
combine.sink-store-self-capture→weak_self(SwiftLint catches the closure form)concurrency.async-sequence-on-self→nullrule, with note that[weak self]does NOT help heredelegate.strong-reference→weak_delegate(SwiftLint catches it directly)
Fix template (v1.7+): every classified cycle also carries a fixTemplate with concrete Swift before/after code snippets. The agent reads the template, then adapts type/method names to the user's codebase via swiftGetSymbolDefinition / swiftSearchPattern. Example output for combine.sink-store-self-capture:
{
"fixTemplate": {
"before": "pub.sink { v in self.value = v }.store(in: &bag) // ⚠️ retains self",
"after": "pub.sink { [weak self] v in self?.value = v }.store(in: &bag)\n// OR for property-path: pub.assign(to: &$value)"
}
}Where staticAnalysisHint says which linter rule complements this, fixTemplate shows what the fix actually looks like in code.
The textual fixHint remains deliberately prose, not code. It explains the why; fixTemplate shows the what. The agent uses both.
memorydetective covers the diagnose side and the source-bridging side. It tells you what is wrong, where in the cycle, what type of fix is needed, where the relevant types live in your project (via Swift LSP integration), and every callsite that references them. It does not edit your code. That final step still belongs to your LLM agent.
So the workflow has two halves:
| Half | Owned by memorydetective |
Owned by the LLM agent |
|---|---|---|
| Diagnose | ✅ memgraph parsing, cycle classification, fix-hint catalog, hangs / allocations / app-launch / animation hitches | |
| Locate in source | ✅ swiftGetSymbolDefinition, swiftFindSymbolReferences, swiftSearchPattern, swiftGetSymbolsOverview, swiftGetHoverInfo (SourceKit-LSP under the hood) |
|
| Decide the actual edit | ✅ The agent reads the surrounding code, picks the right capture-list pattern, writes the diff | |
| Apply the edit | ✅ The agent's Edit/MultiEdit tools write to the user's file |
The split between "locate" (us) and "edit" (the agent) is intentional. Locating is a deterministic SourceKit-LSP query; editing requires understanding the user's surrounding code style, naming conventions, and intent. That's the LLM's strength. We give the agent every piece of structured information it needs to make the edit, and step back at the file-write boundary.
Configuration (one-time): memorydetective registered as an MCP server in Claude Code, as shown in section 1c.
You open Claude Code in your iOS project and ask:
You: Tem um leak. Memgraph em
~/Desktop/example-leaks.memgraph. The cycle points atDetailViewModel. Find where it lives in the project~/Development/myapp/and suggest a fix.
What Claude does, step by step:
-
Calls
analyzeMemgraph(path)- Receives: 60,436 leaks, 4 ROOT CYCLE blocks, top cycle is a
_DictionaryStoragechain. App-level classes in chain:DetailViewModel,GraphQLClient,ItemRepositoryImpl.
- Receives: 60,436 leaks, 4 ROOT CYCLE blocks, top cycle is a
-
Calls
classifyCycle(path)- Receives:
swiftui.tag-index-projection(high confidence) + fix hint pointing at.tag()modifier capturingself.
- Receives:
-
Calls
reachableFromCycle({ rootClassName: "DetailViewModel" })- Confirms 4
DetailViewModelinstances and ~1100NSURLSessionConfigurations reachable from each. The VM is the culprit pinning the network stack as collateral, not the other way around.
- Confirms 4
-
Calls
swiftSearchPattern({ pattern: "\\.tag\\(", filePath: "..." })across views- Surfaces 1 match in
MyApp/Views/MediaCarousel.swift:142.
- Surfaces 1 match in
-
Calls
swiftGetSymbolDefinition({ symbolName: "DetailViewModel", projectRoot: "~/Development/myapp/", candidatePaths: ["MyApp/ViewModels/"] })- Returns
MyApp/ViewModels/DetailViewModel.swift:18.class DetailViewModel: ObservableObject.
- Returns
-
Calls
swiftFindSymbolReferences({ symbolName: "DetailViewModel", filePath: "..." })- Lists 7 callsites across the codebase. Helps the agent gauge fix blast radius.
-
Presents the proposed fix in chat:
Claude: Found the cycle.
MediaCarousel.swift:142passesonImageSliderTapas a closure that capturesself. The.tag(index)modifier on the carouselForEachtriggers the SwiftUI internalTagIndexProjectionobservation that pinsDetailViewModel.Suggested fix:
// before onImageSliderTap: { index in self.viewModel.handlePhotoTap(at: index) } // after. Static helper + weak captures onImageSliderTap: { [weak vm = _viewModel.wrappedValue, weak coord = self.coordinator] index in Self.handlePhotoTap(index: index, viewModel: vm, coordinator: coord) } private static func handlePhotoTap( index: Int, viewModel: DetailViewModel?, coordinator: DetailsCoordinator? ) { /* ... */ }
Want me to apply this?
-
You accept → Claude calls its own
Edittool → diff appears in your IDE → you review and commit. -
(Optional) Verify the fix: export a fresh
.memgraphafter running the same flow, then ask Claude:You: Did the fix work? Compare
~/Desktop/before.memgraphto~/Desktop/after.memgraph.Claude calls
diffMemgraphs. Instance counts dropped, theswiftui.tag-index-projectioncycle is gone fromcycles.persisted, present incycles.goneFromBefore.
Different lane, same shape. You collected .mxdiagnostic payloads from a TestFlight session, dropped them into ~/Downloads/metrickit-may/, and you want to know what's actually happening to real users.
You: Tenho uma pasta
~/Downloads/metrickit-may/com payloads MetricKit dos últimos 30 dias do TestFlight. O que está pegando os usuários?
What Claude does:
-
Calls
analyzeMetricKitPayload({ payloadDir: "~/Downloads/metrickit-may/" })- Returns
payloadCount: 47,timeRange: { start: "2026-04-15…", end: "2026-05-15…" },crashCluster[]with 5 entries,hangHotspots[]with 12 entries,cpuExceptions[]with 3,diskWriteExceptions[]empty.
- Returns
-
Reads the top crash cluster:
{ "clusterKey": "exc=1|sig=11", "exceptionType": 1, "signal": 11, "topFrame": "MyApp 0x1a8c40", "occurrences": 18, "affectedBuilds": ["456", "457"], "sample": { "binaryUUID": "AAAA1111-...", "offsetIntoBinaryTextSegment": 1740864 } }18 crashes on
EXC_BAD_ACCESS(SIGSEGV), all in builds 456 and 457. ThetopFrameis unsymbolicated (memorydetective ships rawbinaryUUID + offsetin v1; dSYM symbolication is a future tool). -
Reads
suggestedNextCalls: the analyzer matchedobjc_releasein the top frame's deeper stack and emitted afindCycleschain hint. Claude surfaces it. -
Presents the synthesis:
Claude: 47 payloads em 30 dias. Top issue: 18 crashes EXC_BAD_ACCESS clusterizados num mesmo top frame, todos nos builds 456 e 457 (não está em 458 nem antes do 456). Padrão de retain-cycle release → recomendo capturar memgraph de uma sessão local rodando o mesmo flow + chamar
findCyclespra identificar o cycle que tá causando o free-after-use.Segundo issue: 1 hang de 5.4s com top frame em
libsqlite3.dylib(3 amostras). Pattern db-lock, sugereanalyzeHangscomincludeStackClassificationnum.tracese você reproduzir o cenário. -
You ship a fix and verify with the next batch of TestFlight payloads (re-run step 1 a week depois, check se
affectedBuildsremoveu o build com o fix).
The pattern is identical to the memgraph example: agent orchestrates discovery + diagnosis, you make the editing decision. Difference is the input lane: synthetic memgraph captured locally vs. production payloads from real users.
If memorydetective tried to generate a code patch, it would have to:
- Parse Swift source
- Understand the file's import graph
- Track the actual variable names and types in scope
- Match surrounding code style
That's exactly what an LLM agent already does. And does well. Splitting the responsibility keeps each side simple. memorydetective knows iOS perf; the agent knows your codebase. They compose.
Once you have the diagnosis, here are useful follow-up prompts you can paste into Claude:
| Prompt | What Claude calls |
|---|---|
| "I want to investigate a memgraph leak. What's the canonical sequence?" | getInvestigationPlaybook({ kind: "memgraph-leak" }). Returns the 6-step pipeline with argsTemplate for each tool. |
"How many DetailViewModel instances are leaking?" |
countAlive(path, className: "DetailViewModel") |
"How many NSURLSessionConfigurations are inside the cycle rooted at DetailViewModel?" |
reachableFromCycle(path, rootClassName: "DetailViewModel", className: "NSURLSessionConfiguration") |
"Show the retain chain that keeps DetailViewModel alive." |
findRetainers(path, className: "DetailViewModel") |
"Compare ~/Desktop/before.memgraph to ~/Desktop/after.memgraph. Did the leak go away?" |
diffMemgraphs(before, after) |
"Did my fix actually resolve the swiftui.tag-index-projection cycle?" |
verifyFix(before, after, expectedPatternId: "swiftui.tag-index-projection"). Returns PASS/PARTIAL/FAIL |
"Whitelist MySingletonCache (exact match) so it does not vote FAIL on the verify." |
verifyFix(before, after, expectedAliveClasses: [{ pattern: "MySingletonCache", mode: "exact" }]). v1.17 introduces per-entry modes ("exact" | "substring" | "regex"); bare strings still default to substring for backwards compat. |
| "Render the cycle as a Mermaid graph for the PR description." | renderCycleGraph(path, format: "mermaid") |
| "Profile this app on my iPhone for 90 seconds and tell me about hangs." | listTraceDevices → recordTimeProfile → analyzeHangs |
"Pull the last 5 minutes of error-level logs from MyApp." |
logShow(last: "5m", process: "MyApp", level: "default") |
| "Run my XCUITest with leak detection." | detectLeaksInXCUITest(workspace, scheme, testIdentifier, …) |
| Production diagnostics (v1.18). For TestFlight / App Store payloads: | |
"I have a .mxdiagnostic file from a user crash. What happened?" |
analyzeMetricKitPayload({ payloadPath: "~/Downloads/payload.mxdiagnostic" }). Returns crashCluster[] ranked by occurrences, with top frame + affected builds. |
"I have a folder of .mxdiagnostic files from the last 30 days. Aggregate them." |
analyzeMetricKitPayload({ payloadDir: "~/MetricKit/", groupBy: "exception-type" }). Walks every .mxdiagnostic in the dir, clusters crashes across all of them, surfaces the top regression. |
| "Group crashes by which framework they originate in (not by exception type)." | analyzeMetricKitPayload({ payloadDir: "...", groupBy: "binary" }). Useful when one third-party SDK is misbehaving across multiple call sites. |
| "What was the longest hang real users hit this week?" | analyzeMetricKitPayload({ payloadDir: "..." }) then read hangHotspots[0].hangDurationMs. Pre-converted to ms from Apple's localized strings ("5.4 sec", "20秒"). |
Verify-fix orchestration (v1.8). For the macOS 26.x leaks regression and deterministic before/after snapshots: |
|
"Build, boot, and launch MyApp ready for leak investigation." |
bootAndLaunchForLeakInvestigation({ workspace, scheme, simulator: { name: "iPhone 15" } }). Returns host PID + UDID + bundle id with MallocStackLogging=1 already applied. |
| "Reproduce the carousel leak: tap Explore, swipe, then back, repeat 5 times." | replayScenario({ simulatorUDID, actions: [{ type: "tap", label: "Explore" }, { type: "swipe", from: [350, 400], to: [50, 400] }, { type: "tap", label: "Back" }], repeat: 5 }) |
"Snapshot before / after the fix into ~/Desktop/snaps/." |
captureScenarioState({ simulatorUDID, pid, outputDir: "~/Desktop/snaps/", label: "before" }) then ship the fix and call again with label: "after". Then diffMemgraphs(before.memgraph, after.memgraph). |
| Source bridging. Combine with the memory tools above: | |
"Where is DetailViewModel declared in this project?" |
swiftGetSymbolDefinition(symbolName, candidatePaths) |
"Find every reference to DetailViewModel across the codebase." |
swiftFindSymbolReferences(symbolName, filePath) |
"What types live in MediaCarousel.swift?" |
swiftGetSymbolsOverview(filePath) |
| "What's the type at this position in this file?" | swiftGetHoverInfo(filePath, line, character) |
"Search for [weak self] captures in this file." |
swiftSearchPattern(filePath, pattern: "\\[weak self\\]") |
The agent decides which tool to call based on your prompt. You don't need to remember the tool names.
The npm global install isn't on your $PATH. Check:
which memorydetective
npm prefix -gIf npm prefix -g returns something not in your $PATH, add it. Or use the binary directly:
$(npm prefix -g)/bin/memorydetective --versionKnown limit. xcrun xctrace export of the time-profile schema crashes on heavy unsymbolicated traces. Workarounds (in order of effort):
- Open the trace once in Instruments.app (forces symbolication), then close it. Re-run
analyzeTimeProfile. - Re-record with a shorter
--time-limit(try 30 s instead of 90 s). - For hang analysis specifically, use
analyzeHangsinstead. It parses a different (lighter) schema that doesn't crash.
By design. leaks(1) only attaches to processes on the local Mac (which includes iOS simulators). Memory Graph capture from a physical device goes through Xcode's debugger over USB. Different mechanism, no public CLI equivalent. Use Xcode's Memory Graph button + File → Export Memory Graph for physical devices.
captureMemgraph returns workaroundNotice: { issue: "macos-26-task-for-pid-broken" } or "minimal-corpse"
There are two related-but-distinct failure modes here. v1.9 routes them apart so the agent (and you) can pick the right recovery without trial and error.
minimal-corpse fires when the target process was not launched with MallocStackLogging=1. Relaunching with the env var typically resolves it (see bootAndLaunchForLeakInvestigation).
macos-26-task-for-pid-broken fires on macOS 26.x (Darwin kernel 25.x) when leaks fails with the same DYLD-info signature, but where the root cause is Apple's task_for_pid kernel regression. MallocStackLogging does not resolve it because the regression sits below the libmalloc layer. Other CLI memory-introspection tools (heap, xctrace --template Allocations) hit the same wall on the same host. The reliable workaround is target an iOS 18 simulator runtime instead (install via Xcode > Settings > Platforms > +iOS 18.x). This was empirically validated during the notelet investigation 2026-05-12 where every CLI path failed on iPhone 17 / iOS 26.4 sim but worked first-try on iPhone 16 / iOS 18 sim with the same demo app and capture flow.
memorydetective surfaces both issue ids in the structured shape:
{
"ok": false,
"pid": 49581,
"workaroundNotice": {
"issue": "minimal-corpse",
"message": "leaks --outputGraph could not introspect the target process...",
"fallbacks": [
"Relaunch the app with MallocStackLogging=1 (use bootAndLaunchForLeakInvestigation).",
"Open Xcode > Debug > View Memory Graph Hierarchy + File > Export Memory Graph.",
"Record an Allocations trace via recordTimeProfile + analyzeAllocations."
]
},
"suggestedNextCalls": [
{ "tool": "recordTimeProfile", "args": { "template": "Allocations", ... } },
{ "tool": "analyzeAllocations", "args": { ... } }
]
}Recovery options:
For minimal-corpse on non-macOS-26.x hosts:
- Use
bootAndLaunchForLeakInvestigation. Single call that builds, boots, installs, and launches with the right env vars in one shot. Returns the host PID ready forcaptureMemgraph. This is the canonical fix. - Xcode manual export. With the app attached to Xcode debugger: Debug > View Memory Graph Hierarchy, then File > Export Memory Graph. Pass the resulting
.memgraphtoanalyzeMemgraph. - Allocations fallback. Follow
suggestedNextCallsto record anAllocationstrace and inspect withanalyzeAllocations. Not full cycle detection but reveals top live classes.
For macos-26-task-for-pid-broken on macOS 26.x:
- iOS 18 simulator runtime (canonical). Install via Xcode > Settings > Platforms > +iOS 18.x. Build + launch your app on the iOS 18 sim instead of the macOS 26.x sim.
leaks,captureMemgraph,captureScenarioState, and all downstream tools work normally there. This is the only fully-automatable path today. - Xcode manual export with the scheme toggle. Edit your Run scheme: Diagnostics tab > check Malloc Stack Logging. Then
Cmd+R, drive the scenario, pause the debugger, Debug > View Memory Graph Hierarchy, File > Export Memory Graph. Without the scheme toggle, View Memory Graph fails withVMUTaskMemoryScannererrors on macOS 26.x. recordTimeProfileAllocations trace. Run the Allocations template against the live process, thenanalyzeAllocationsfor class counts. Avoid--time-limitpast 30s on macOS 26.x sims due to a separate xctrace bug (seerecordTimeProfilenotes).
Set MEMORYDETECTIVE_SUPPRESS_PLATFORM_ADVISORY=1 to silence the proactive stderr banner once you have settled on a workaround.
The getInvestigationPlaybook({ kind: "memgraph-leak" }) response carries a troubleshooting field with these recovery paths inline so an LLM agent can branch deterministically.
xcrun xctrace record --time-limit Ns is broken on macOS 26.x for simulator targets. The recording wedges past the requested time limit, exits when SIGKILL fires, and produces a .trace bundle that contains only Trace1.run/RunIssues.storedata (no actual schema data). xctrace export --toc against the resulting bundle returns Document Missing Template Error. The bug is upstream (Apple) and survives Xcode 26.5 (build 17F42, xctrace 16.0) per the 2026-05-15 re-validation.
The symptom in memorydetective:
{
"ok": false,
"recordingTimedOut": true,
"tracePath": "/tmp/x.trace",
"workaroundNotice": {
"issue": "macos-26-xctrace-record-broken",
"...": "..."
}
}It hits every xctrace-based tool the same way (this MCP, XcodeTraceMCP, raw xcrun xctrace record calls in a shell, third-party scripts wrapping the CLI). Not a memorydetective bug.
Recovery options, ranked by automation cost:
recordViaInstrumentsAppMCP tool (v1.16+). Opens Instruments.app for you, surfaces step-by-step instructions in the response, then watches a directory for the saved.trace. Once it appears, the tool returns the path plus a chainedinspectTracesummary. The user-in-loop step is the recording itself (pick template, hit Record, hit Stop, hit Save). Why is it user-in-loop? Instruments.app's AppleScript surface is minimal: queries on thedocumentclass only, no verbs for start/stop/select-template (seeXcode.app/Contents/Applications/Instruments.app/Contents/Resources/Instruments.sdef). The watcher polls every 5 seconds and considers a bundle "saved" after its mtime is stable for 10 seconds. v1.17 fixed the common "I hit Save and accepted the Desktop default" miss: the watcher now also queries running Instruments.app via AppleScript each poll for any document whose file path was set after the tool started. On match, returns the path withsavedOutsideWatchDir: trueso you do not have to re-record intowatchDir.- Record on an older macOS host with Xcode 26.0 if you have one available. Pre-regression
xctrace recordproduces clean traces. The 2026-05-15 validation used~/Desktop/wishlist-tti-device.trace, a Time Profiler trace captured this way: 91s recording, 35 hangs detected, 44 418 time-profile samples, fully analyzable by memorydetective's trace-side tools. - Record manually via Instruments.app GUI without the wrapper. Instruments.app on macOS 26.x still produces valid
.tracebundles. Open Instruments, pick a template, choose the simulator + app, hit Record, drive the scenario, Stop, Save. Then pointinspectTrace/summarizeTrace/analyzeHangs/analyzeTimeProfile/compareTracesByPatternat the saved bundle. All trace-side analyzers work normally on Instruments-recorded.tracebundles. - Record against a physical device (USB or wireless). The regression appears to be simulator-specific.
xctrace record --device <UUID> --launch <app>against a real iPhone / iPad does NOT exhibit the same wedge. Use this when you have a physical target available. - Wait for Apple. The Feedback assistant is the right escalation path. The regression has shipped through Xcode 26.4 + 26.5; expect a fix in 26.6 or later.
memorydetective's recordViaInstrumentsApp (v1.16, hardened in v1.17) + pre-flight probe (v1.14) cover the automated paths around the regression until Apple ships a fix. v1.17 also added a bundleStatus: "unknown" \| "salvageable" \| "wedged" field on recordTimeProfile responses and a fault-tolerant fallback on inspectTrace so the entire MCP call no longer throws when xctrace refuses a wedged bundle.
The axe CLI is not on your $PATH. Install with:
brew install cameroncooke/axe/axeaxe is a soft dependency. Only replayScenario and the uiTree sub-capture of captureScenarioState need it. Other tools work without it.
The stress test has a wallclock budget that's tighter on slower runners. If you see expected NNNms to be less than 2000, bump PARSE_BUDGET_MS in src/stress.test.ts.
The app process exited before leaks --outputGraph could attach. Configure your XCUITest to keep the app alive at end-of-test (e.g. XCTAssertTrue(true); _ = XCTWaiter.wait(for: [...], timeout: 1.0)), or use a longer simulator boot. This tool is experimental in v1.0. Feedback welcome.
Discovery is data, not inference. As of v1.3, the tools that matter most return a suggestedNextCalls field with pre-populated arguments and a one-sentence rationale per entry. The orchestrating agent can chain calls without re-reasoning over the result.
{
"ok": true,
"totalCycles": 4,
"classified": [ /* ... */ ],
"suggestedNextCalls": [
{
"tool": "swiftSearchPattern",
"args": {
"pattern": "\\.tag\\(",
"filePath": "<set to a candidate Swift file in your project>"
},
"why": "Locate the code construct implicated by swiftui.tag-index-projection. The regex matches the SwiftUI signal that produces this cycle."
},
{
"tool": "swiftGetSymbolDefinition",
"args": {
"symbolName": "DetailViewModel",
"candidatePaths": ["<set to a Sources/ or app target directory>"]
},
"why": "Jump to the declaration of DetailViewModel, the user-defined type captured in this cycle."
}
]
}The agent reads suggestedNextCalls, fills in the <...> placeholders from project context, and chains. No re-reasoning required.
For agents that haven't seen the project before, ask for the canonical pipeline first:
{
"tool": "getInvestigationPlaybook",
"args": { "kind": "memgraph-leak" }
}Returns a 6-step sequence with argsTemplate per step:
{
"kind": "memgraph-leak",
"summary": "Diagnose a SwiftUI / Combine retain cycle from a `.memgraph` snapshot, locate the offending code, and propose a fix.",
"steps": [
{ "step": 1, "tool": "analyzeMemgraph", "purpose": "..." },
{ "step": 2, "tool": "classifyCycle", "purpose": "..." },
{ "step": 3, "tool": "reachableFromCycle", "purpose": "..." },
{ "step": 4, "tool": "swiftSearchPattern", "purpose": "..." },
{ "step": 5, "tool": "swiftGetSymbolDefinition", "purpose": "..." },
{ "step": 6, "tool": "swiftFindSymbolReferences", "purpose": "..." }
]
}Five playbooks ship in v1.3:
| Kind | Use when |
|---|---|
memgraph-leak |
You have a .memgraph and want to find + fix a retain cycle |
perf-hangs |
App feels slow; suspect main-thread blocking |
ui-jank |
Animations drop frames |
app-launch-slow |
Cold-start time is over budget |
verify-fix |
Confirm a fix actually resolved the cycle |
Every tool description starts with a category tag so related tools are visible as a group:
| Tag | What |
|---|---|
[mg.memory] |
memgraph parsing, cycle classification, retainer chains |
[mg.trace] |
xctrace schemas (hangs, allocations, app-launch, animation hitches, time-profile) |
[mg.code] |
Swift source bridging via SourceKit-LSP |
[mg.log] |
macOS unified logging (log show / log stream) |
[mg.discover] |
xctrace device + template listing |
[mg.render] |
Cycle visualization (Mermaid + Graphviz) |
[mg.ci] |
XCUITest leak detection |
[meta] |
Pipeline-discovery tools like getInvestigationPlaybook |
The tag is leading text in the MCP description, so it shows up in any tools/list output and inside Claude Code's "deferred tools" list.
Since v1.6, memorydetective surfaces two MCP-spec features beyond raw Tools.
Each of the 33 catalog patterns is exposed as a read-only MCP resource at memorydetective://patterns/{patternId}. The body is markdown. The pattern name, the fix hint, and a footer pointing at how it composes with classifyCycle's primaryMatch.
// resources/list response (excerpt)
{
"resources": [
{
"uri": "memorydetective://patterns/swiftui.tag-index-projection",
"name": "SwiftUI .tag(...) closure-over-self cycle",
"description": "SwiftUI .tag(...) closure-over-self cycle",
"mimeType": "text/markdown"
},
{
"uri": "memorydetective://patterns/concurrency.async-sequence-on-self",
"name": "`for await` over an infinite AsyncSequence pins self via the consuming Task",
"description": "`for await` over an infinite AsyncSequence pins self via the consuming Task",
"mimeType": "text/markdown"
},
…
]
}Why this matters: an agent that needs to ask "do you cover X?" can browse the resource list cheaply (no tool call). A UI-aware client can render the catalog as a sidebar or completion source.
Five prompts ship, one per investigation kind:
| Prompt name | Surfaces in Claude Code as | Args | Equivalent tool sequence |
|---|---|---|---|
investigate-leak |
/investigate-leak |
memgraphPath |
analyzeMemgraph → classifyCycle → reachableFromCycle → swiftSearchPattern → swiftGetSymbolDefinition → swiftFindSymbolReferences |
investigate-hangs |
/investigate-hangs |
tracePath |
listTraceDevices → recordTimeProfile (if needed) → analyzeHangs → swiftSearchPattern |
investigate-jank |
/investigate-jank |
tracePath |
recordTimeProfile (Animation Hitches template) → analyzeAnimationHitches → swiftFindSymbolReferences |
investigate-launch |
/investigate-launch |
tracePath |
recordTimeProfile (App Launch template) → analyzeAppLaunch → swiftSearchPattern |
verify-cycle-fix |
/verify-cycle-fix |
before, after |
diffMemgraphs → classifyCycle |
When the user invokes a prompt, the server fills the canonical playbook's argument templates with the user-provided values and returns a ready-to-execute brief. The agent then executes the steps. Same tool calls as if the user had typed them out, just orchestrated.
Both surfaces (Tools + Resources + Prompts) are independent. Clients that only support Tools still get the full catalog via
classifyCycle. Resources and Prompts are pure-add UX improvements for clients that surface them.
- Add a new cycle pattern: see the Adding a cycle pattern to
classifyCyclesection inREADME.md. - Run a custom analysis from scratch: every tool's input schema is documented via the MCP
tools/listrequest. Hit the server with{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}over stdio. - Open an issue: https://github.com/carloshpdoc/memorydetective/issues. Bug reports, feature requests, and pattern contributions are all welcome.