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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Packaging/skills/space/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,16 @@ on an external drive. That judgement is the entire value you add.
- **Never propose `rm -rf` for a path `explain` reports as a cleanup target.**
SpaceMatters empties those itself, fenced to the user's home, never following
symlinks, and journalled. Point at Low-Hanging Fruits in the app instead.
This covers more than a fixed list: `bin`/`obj`/`target` beside a project
file, and orphaned editor workspace state, are recognised too.
- **`explain` also answers "NOT a cleanup target", and that answer outranks
your own reading.** It is how you learn that a `bin/` is a virtualenv's, or
that a `workspaceStorage` folder belongs to a project still on disk. A
directory that looks obviously disposable from its name and size is exactly
the case this exists for.
- **Never call anything "safe" that you have not run `explain` on.**
- **Check whether the owning app is running** before recommending an app's
cache. Its files are held open; deleting them frees nothing until quit.
- **Documents, photos, music and videos are never `safe`.** They are `review`
at most, and the recommendation is to move them, not to delete them.
- **Application Support is not a cache directory.** Some of it is state the app
Expand Down
92 changes: 86 additions & 6 deletions Packaging/skills/space/references/directories.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,49 @@ that matters.
| `~/Library/Caches/pip`, `~/Library/Caches/uv` | Python wheel caches | Re-download. |
| `~/Library/Developer/Xcode/DerivedData` | Per-project build products, indexes | Rebuild, and the first build after is slow. |
| `~/Library/Caches/org.swift.swiftpm`, `~/Library/Caches/CocoaPods` | Dependency caches | Re-download. |
| `~/.npm/_npx` | Throwaway install tree per `npx <pkg>`, never evicted | Re-download. Routinely larger than `_cacache` — check both. |
| `node_modules`, `.venv`, `target`, `build`, `bin`, `obj`, `__pycache__`, `.next`, `dist` | Per-project build/dependency trees | Reinstall or rebuild. Individually small, collectively often the single largest finding — always `find` them. |

### `bin/` and `obj/` are not safe by name

On a .NET machine these are usually the largest single finding — 20 GiB across
1,722 folders on a real disk. They are also the most dangerous name to sweep: a
Python virtualenv keeps its interpreter, `pip` and `activate` in `bin/`, and Go
projects keep compiled binaries there. A bare
`find ~ -type d -name bin -exec rm -rf {} +` destroys every virtualenv on the
disk.

What makes one safe is a **sibling project file** — `*.csproj`, `*.fsproj`,
`*.sln` next to the `bin/`, `Cargo.toml` next to a `target/`. Run `explain` on
any such folder: SpaceMatters applies exactly that rule and tells you which side
the folder falls on. It cleans the qualifying ones itself.

Do **not** recommend `dotnet clean` as the tidy alternative. It cleans one
configuration of one project, leaves `Release/` and `project.assets.json` in
place (measured: 28% of `bin`+`obj` freed on a two-configuration build), needs a
restore, and fails outright when a `global.json` pins an SDK that is not
installed. Removing the directory is both more complete and faster.

## Grows without bound, nobody notices

- **`~/Library/Application Support/Code/User/workspaceStorage`** — one folder per
workspace VS Code has ever opened, keyed by a hash. Holds editor state, chat
sessions (`chatSessions`, `.jsonl`) and per-extension databases (`.vscdb`).
**It keeps folders for repositories deleted years ago** and never prunes them.
Frequently gigabytes. Deleting a hash folder loses that workspace's local
history and chat transcripts, not the code — that is the real trade-off to
present, not "it's a cache".
workspace VS Code has ever opened, keyed by a hash. Frequently gigabytes, and
the single most misjudged directory on this list. Two things are true at once
and both get missed:

1. **The bulk of it is AI chat transcripts.** On a real machine: 7.9 GiB
`chatSessions` + 1.2 GiB `chatEditingSessions` out of 10.7 GiB — 85%.
Nothing regenerates those. "It only costs editor layout and undo history"
is simply false; never write it.
2. **Most folders are still live.** The folder does keep state for deleted
repositories, but far less than it looks: 9 of 192 on the same machine,
0.11 GiB of the 10.7. "Most of these belong to repos you deleted long ago"
is an assumption, and measuring it takes one read per folder.

Each hash folder holds a `workspace.json` naming the project it belongs to, so
orphan status is *decidable*, not a guess. Run `explain` on the directory —
SpaceMatters counts live vs orphaned workspaces for you — and never propose
emptying the whole thing. The app offers the orphaned folders only.
- **`~/Library/Caches/JetBrains/<Product><Version>`** — one tree per IDE version
ever installed. Old versions are pure waste once uninstalled; the current one
regenerates but reindexing is slow. `~/Library/Application Support/JetBrains`
Expand All @@ -42,11 +74,39 @@ that matters.
- **Electron app caches** (`Slack`, `Notion`, `Discord`, …) under
`Application Support/<app>/{Cache, Code Cache, GPUCache, Service Worker}` —
refetched. Their siblings are not: see below.
- **`~/Library/Caches` as a whole is not one thing.** It mixes three
populations: per-app caches keyed by bundle id (`us.zoom.xos`,
`com.tinyspeck.slackmacgap`), developer tool caches with plain names
(`colima`, `trivy`, `goimports`, `ms-playwright`), and system state that is
not an app cache at all (`CloudKit`, `com.apple.*`). Never recommend emptying
the directory; name the subdirectories.

**Check whether the owning app is running before recommending its cache.** A
desktop app holds its cache open for hours, so unlinking underneath it frees
nothing until quit and can leave the app's own index pointing at files that no
longer exist. This is a real case, not a hypothetical: Chrome, Firefox, Slack
and Zoom caches are often several GB and those apps are usually open.
SpaceMatters blocks such a target while its app runs — recommend quitting
first, or point at the app's cleanup pass.

`ms-playwright` deserves its own wording: it is browser *binaries*, not
fetched assets. Nothing re-downloads them on demand, and every e2e test fails
until `npx playwright install` is run by hand. Regenerable, but not
automatically — say so.

## Looks like a cache, holds state

Deleting these loses data the app cannot get back.

- **A tool's `~/.cache/<name>` and its `~/.local/share/<name>` are opposites**,
and the matching names invite exactly the wrong conclusion. opencode is the
worked example: `~/.cache/opencode` is downloaded helper binaries and a model
list (disposable), while `~/.local/share/opencode` holds `auth.json` and
`opencode.db` — the login token and every conversation. Never generalise a
verdict from one to the other because the tool's name matches; check which of
the two you are looking at. The same split applies to most XDG-shaped tools:
`.cache` is disposable, `.local/share` and `.config` are not.

- `Application Support/*/IndexedDB`, `Local Storage`, `Session Storage`,
`Databases` — app state, drafts, unsynced edits.
- `Application Support/*/File System`, `blob_storage` — offline attachments.
Expand Down Expand Up @@ -84,3 +144,23 @@ bundles, `.ext4`/`.raw`/`.qcow2` files. `explain` reports the gap. Deleting
frees the on-disk figure, not the apparent one — and the tool's own reclaim
command (`docker system prune`, `podman system prune`, `colima delete`) is
almost always the better answer than removing files under it.

**Pruning inside a VM does not shrink its disk image — but that does not mean
the space is lost.** The guest frees the blocks; the host file keeps them
allocated until the guest issues a discard for them. So a `podman system prune`
that reclaims 15 GB inside can leave the `.raw` byte-for-byte as large as
before. Do not conclude from this that "podman disks only grow, recreate the
machine": Podman's applehv backend passes discard through (`lsblk -D` in the
guest shows a non-zero `DISC-MAX`), and Fedora CoreOS runs `fstrim.timer`
weekly. The blocks come back — just up to a week late.

The right sequence is prune, then trim: SpaceMatters' container mode has a
**Reclaim host disk** button that runs `fstrim` in the machine and reports the
measured change in the image file. By hand it is
`podman machine ssh sudo fstrim /var`. Two cautions:

- Never quote `fstrim`'s own output as space recovered. It prints the size of
the free extents it walked — "39.5 GiB trimmed" for 2.06 GB actually returned,
measured. Only the image file's on-disk size before and after is the truth.
- `podman machine set --disk-size` grows a machine and cannot shrink one; there
is no resize path here, and none is needed, because the file is sparse.
48 changes: 45 additions & 3 deletions Sources/SpaceMatters/App/ClaudeIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ enum ClaudeIntegration {
var skillSource: URL?
var skillDestination: URL
var skillAlreadyInstalled: Bool
/// The installed skill differs from the one in this build.
///
/// Treating "the directory exists" as "the skill is current" is how a
/// correction shipped in the app never reaches the assistant reading
/// it: the install runs once, and every later version of the guidance
/// stays in the bundle. The failure is silent and it is the worst kind
/// — the model keeps answering confidently from advice we already knew
/// was wrong.
var skillOutdated: Bool
var executablePath: String

var registerCommand: String {
Expand All @@ -41,15 +50,48 @@ enum ClaudeIntegration {

static func plan() -> Plan {
let destination = URL(fileURLWithPath: NSHomeDirectory() + "/.claude/skills/space")
let source = Bundle.main.url(forResource: "space", withExtension: nil,
subdirectory: "skills")
let installed = FileManager.default.fileExists(atPath: destination.path)
return Plan(
cliPath: cliCandidates.first { FileManager.default.isExecutableFile(atPath: $0) },
skillSource: Bundle.main.url(forResource: "space", withExtension: nil,
subdirectory: "skills"),
skillSource: source,
skillDestination: destination,
skillAlreadyInstalled: FileManager.default.fileExists(atPath: destination.path),
skillAlreadyInstalled: installed,
skillOutdated: installed && source.map {
!treesMatch($0, destination)
} ?? false,
executablePath: Bundle.main.executablePath ?? CommandLine.arguments[0])
}

/// Whether two skill directories hold the same files with the same bytes.
///
/// A content comparison rather than a version number, because the thing that
/// matters is whether the assistant is reading what this build says — and
/// because it also catches a hand-edited copy, which is a reason to ask
/// before overwriting rather than a reason to overwrite. The tree is a
/// handful of small Markdown files, so reading it whole costs nothing.
/// Anything unreadable counts as a mismatch: fail towards offering the
/// update, never towards silently keeping stale guidance.
static func treesMatch(_ lhs: URL, _ rhs: URL) -> Bool {
func files(_ root: URL) -> [String: Data]? {
guard let walker = FileManager.default.enumerator(
at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { return nil }
var out: [String: Data] = [:]
for case let url as URL in walker {
guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
else { continue }
// Finder metadata is not content and must not force an update.
if url.lastPathComponent == ".DS_Store" { continue }
guard let data = try? Data(contentsOf: url) else { return nil }
out[url.path.replacingOccurrences(of: root.path, with: "")] = data
}
return out
}
guard let left = files(lhs), let right = files(rhs) else { return false }
return left == right
}

/// Is the server registered at user scope?
///
/// Read from the config rather than asked of `claude mcp list`: that command
Expand Down
41 changes: 36 additions & 5 deletions Sources/SpaceMatters/App/MCP/MCPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ final class MCPServer: @unchecked Sendable {

private let source: any MCPScanSource
private let detachedReason: DetachedReason?
/// Memoized `CleanupDiscovery.all()` — the walk costs seconds, and only
/// `cleanup_targets` needs it. Filled on first use, then reused for the
/// lifetime of the server.
private var discoveredTargets: [Cleanable]?

init(source: any MCPScanSource, detachedReason: DetachedReason? = nil) {
self.source = source
Expand Down Expand Up @@ -328,24 +332,51 @@ final class MCPServer: @unchecked Sendable {
+ "SpaceMatters cleans this itself, fenced and journalled; do not propose a "
+ "shell command for it.\n"
}
// Targets whose paths are discovered rather than listed. Both answers
// are worth printing: the ones the app will clean, and the ones it
// deliberately refuses — a `bin/` with no project beside it, a workspace
// whose folder is still there. Those refusals are the advice that a
// size table alone would get wrong.
switch CleanupDiscovery.classify(path) {
case .cleanable(let name, let note):
out += "Known cleanup target \"\(name)\" (discovered) — \(note) SpaceMatters cleans "
+ "this itself, fenced and journalled; do not propose a shell command for it.\n"
case .protected(let reason):
out += "NOT a cleanup target — \(reason)\n"
case nil:
break
}
return out
}

private func cleanupTool(_ index: TreeQuery.Index) -> String {
let detected = CleanupEngine.detect(CleanupEngine.catalog())
// The discovery walk takes seconds, so it runs once per server rather
// than per call — this tool is the only caller, and the disk does not
// change shape between two calls in one conversation.
if discoveredTargets == nil { discoveredTargets = CleanupDiscovery.all() }
let detected = CleanupEngine.detect(CleanupEngine.catalog() + (discoveredTargets ?? []))
guard !detected.isEmpty else {
return caveat(full: false) + "no known cleanup target exists on this machine."
}
var out = caveat(full: false) + """
Locations SpaceMatters can safely empty itself (regenerable by design). Sizes come from \
this scan and are blank for anything outside its root — they are not re-measured here.
Locations SpaceMatters can safely empty itself. Sizes come from this scan and are blank \
for anything outside its root — they are not re-measured here.

"""
for item in detected {
let sizes = item.paths.compactMap { index.node(at: $0)?.sizeOnDisk }
let measured = sizes.isEmpty ? "—" : Format.bytes(sizes.reduce(0, +))
out += "\(measured) [\(item.id)] \(item.name) · \(item.category) — \(item.note)\n"
for path in item.paths { out += " \(path)\n" }
let permanence = item.regenerable ? "" : " NOT REGENERABLE — this is state, not a cache."
out += "\(measured) [\(item.id)] \(item.name) · \(item.category) — "
+ "\(item.note)\(permanence)\n"
// A discovered target can carry hundreds of paths; a token budget is
// better spent on the count and a sample than on the full list.
if item.paths.count > 6 {
out += " \(item.paths.count) locations, e.g.\n"
for path in item.paths.prefix(3) { out += " \(path)\n" }
} else {
for path in item.paths { out += " \(path)\n" }
}
}
return out
}
Expand Down
Loading
Loading