I opened [[Pieces.app]] today and was greeted with this message: > After Sunday, August 16, 2026 at 11:59:59 PM UTC, creating new memories, generating workstream summaries, and using Agentic Chats will require a paid plan. You can still read, browse, and query your existing memories and chats in Pieces and through MCP. You do not need a paid plan for that access. As of August 10, 2026, your Pieces history includes 200K+ memories across 20 months. Thank you for making Pieces part of your work. We don't take that trust, or this change, lightly. Long-term context and Agentic Chats have ongoing infrastructure costs. Paid plans let us keep those services reliable and continue improving Pieces. We understand that paying for capabilities that were free may be disappointing. I have used Pieces for the past few years to save code snippets I wanted to reference later. I don't use enough of the other functionality to justify adding another subscription, so my first reaction was pretty simple: uninstall it. There was just one problem. I had **358 code snippets** saved in Pieces, some dating back almost three years, and I didn't want uninstalling the app to mean losing them. Since I already keep the rest of my notes in [[Obsidian.app]], [[Markdown]] seemed like the obvious destination. ## Finding the Data My first thought was to use the [[pieces.cli|Pieces CLI]]. Unfortunately, while the CLI can list materials, it doesn't currently provide a convenient machine-readable bulk export command. [[PiecesOS.app]] was already running locally on my [[macOS|Mac]], though, and in my case it was listening on port `39300`. It turns out we could grab all of my saved assets directly: ~~~ curl -fsS http://localhost:39300/assets -o ./tmp/pieces-assets.json ~~~ That gave me a single [[JSON]] snapshot containing all **358** of my saved materials. At this point I technically had a backup, which was the most important part. Even if the rest of the migration went terribly wrong, I now had the original data outside of Pieces. ## Understanding the Export The JSON was a little more complicated than I expected. Most snippets stored their original contents here: ~~~ original.reference.fragment.string.raw ~~~ But one of my 358 snippets stored its contents as an array of bytes instead: ~~~ original.reference.file.bytes.raw ~~~ So the migration script needed to support both formats. Before writing anything to my Obsidian vault, I spent some time validating the export rather than assuming the API response was consistent. A few things we found: - All 358 assets had unique Pieces IDs. - All 358 snippets were recoverable. - All snippet contents were valid UTF-8. - 357 snippets stored their contents as strings. - 1 snippet stored its contents as bytes. - 65 snippets ended with a newline. - 293 did not. - 40 snippets contained triple backticks. - Several snippets shared the same title. - Some snippets had completely identical contents but different metadata. I decided **not to deduplicate anything**. If Pieces thought I had 358 saved materials, I wanted 358 Markdown files when the migration finished. ## Pieces Metadata Was... Interesting Pieces stores quite a bit more than the snippet itself. There were descriptions, tags, websites, classifications, commit messages, timestamps, and other generated metadata attached to the assets. Initially I thought I would preserve most of that in the resulting Markdown files. After actually inspecting it, I changed my mind. For example, Pieces had generated descriptions like: > The code snippet below calculates the factorial of a given number using recursion. For a snippet named `Database Query Optimization Strategies`. Other descriptions were truncated, inaccurate, or clearly generated from unrelated context. Tags had similar problems. There were over 6,000 tag relationships, but almost all of them had been generated automatically. Some examples included things like: ~~~ Framework: Sorry, but I can't generate a response based on the given code snippet ~~~ and: ~~~ Pages related to {{value:Link}}... ~~~ That is not metadata I particularly want permanently attached to my Obsidian notes. So I ended up being fairly conservative about what made it into the human-readable files. ## What I Kept Each generated note contains a small amount of frontmatter: ~~~ --- type: snippet source: "[[Pieces.app]]" language: shell created_on: 2023-11-21 updated_on: 2023-11-21 --- ~~~ If I had manually added tags in Pieces, those are included too: ~~~ --- type: snippet source: "[[Pieces.app]]" language: javascript created_on: 2024-09-19 updated_on: 2024-09-19 tags: - obsidian - quickadd --- ~~~ Only **7 tags across 5 snippets** turned out to be explicitly manual tags. I also kept a `source_url` when Pieces had a trustworthy original source URL. There were quite a few website relationships in the export, but most were automatically generated "Relevant Site" links. Some were Google searches, GitHub tag pages, temporary Raindrop cache URLs, or even JavaScript chunks from websites. After filtering those out, only **20 of the 358 snippets** had a source URL that I felt was worth including. ## What I Didn't Keep I deliberately left these out of the Markdown files: - Pieces IDs - generated descriptions - generated tags - generated website associations - generated Git commit messages - other Pieces-specific enrichment I still preserve all of this information in the raw JSON export and a normalized JSONL archive. That means nothing is actually lost, but my Obsidian notes don't inherit years of questionable generated metadata. There was one exception. One snippet had an explanation I had manually written inside Pieces: > The picker template when run will check if the note you've created was created in the default location... That was clearly useful context I had authored myself, so the exporter keeps manual explanations in an optional `## Explanation` section. ## Fixing Language Detection Pieces also wasn't always correct about programming languages. For example, this: ~~~ mdfind kMDItemAppStoreHasReceipt=1 ~~~ was classified as `BatchFile`. That might technically fall into Pieces' idea of command-line code, but it definitely isn't a Windows batch file. Since I want Obsidian's syntax highlighting to work correctly, the migration script includes a small correction layer. If Pieces reports `BatchFile` but the contents contain obvious Unix shell commands or syntax such as `brew`, `git`, `curl`, `grep`, `mdfind`, environment variables, pipes, or shell conditionals, I treat it as shell instead. I did the same for GraphQL, AppleScript, and a few other languages. There were also 13 particularly strange classifications that were easier to correct explicitly after inspecting them. ## Handling Markdown Code Fences This was another edge case I almost missed. Forty of my snippets contained triple backticks themselves. If I blindly generated this: ~~~ ```markdown some markdown containing ``` ``` ~~~ I could accidentally terminate the outer code block. Instead, the exporter determines the longest consecutive run of backticks inside each snippet and uses a fence at least one character longer. So a snippet containing: ~~~ ``` ~~~ might be wrapped in: ~~~ ```` ... ```` ~~~ It is a small detail, but exactly the kind of thing that can silently corrupt a bulk Markdown migration. ## Duplicate Titles Pieces allows multiple snippets to have the same name. I had 16 groups of duplicate names, including: - `Rails routes fuzzy finder` - `Git file search and run Ruby test` - `Generate Obsidian Plugin Metadata Files` - `Remove processed tag from active Markdown file` For unique titles, I use the title directly: ~~~ Find All Apps Installed from App Store.md ~~~ If multiple snippets resolve to the same filename, **all** members of the collision group get part of their Pieces ID appended: ~~~ Rails routes fuzzy finder--fa3b8c22.md Rails routes fuzzy finder--7db5e524.md ~~~ I didn't want the filename chosen for one duplicate to depend on whichever asset happened to appear first in the JSON. The filenames should be deterministic every time the exporter runs. ## The Result A generated note looks roughly like this: ~~~ --- type: snippet source: "[[Pieces.app]]" language: shell created_on: 2023-11-21 updated_on: 2023-11-21 --- ## Code ```bash mdfind kMDItemAppStoreHasReceipt=1 ``` ~~~ Pretty boring. That's exactly what I wanted. My snippets are now plain Markdown files that I can search, edit, link, version with Git, process with Ruby, query from Obsidian, or move somewhere else entirely later. There is no special application required to read them. ## Keeping the Original Export I didn't want the conversion to Markdown to become the new source of truth immediately, so the exporter produces a few different artifacts: ~~~ pieces-export/ ├── raw/ │ └── assets.json ├── normalized/ │ └── assets.jsonl ├── markdown/ │ └── ... └── manifest.json ~~~ `raw/assets.json` is the untouched response from PiecesOS. `normalized/assets.jsonl` contains a more convenient representation of the data, including metadata that I intentionally left out of Obsidian. `manifest.json` maps every Pieces asset to its Markdown filename and includes hashes of the original contents. That gives me something I can use to verify the migration instead of looking at a folder with 358 files and hoping everything worked. ## Why I Went This Route I could have just copied the visible snippet contents out of Pieces and called it a day. But bulk migrations are exactly where small inconsistencies become annoying later. I wanted to know that: - every Pieces asset produced a Markdown file - every original snippet was preserved exactly - duplicate titles didn't overwrite each other - metadata filtering was intentional - rerunning the exporter produced the same filenames - I still had the untouched source data if I discovered a bug The most important part of the entire process was probably the first command: ~~~ curl -fsS http://localhost:39300/assets -o ./tmp/pieces-assets.json ~~~ Once I had that file, Pieces was no longer the only place containing my snippets. Everything after that was just transformation. ## Finished Script ```rb #!/usr/bin/env ruby # frozen_string_literal: true require "date" require "digest" require "fileutils" require "json" require "optparse" require "securerandom" require "time" EXPECTED_ASSET_COUNT = 358 REAL_LUA_ASSET_ID = "eb6ef70c-fd33-47a1-967e-7c6e246384f6" LANGUAGE_OVERRIDES = { "c379d282-ca8b-493e-a4df-fa010ebc3797" => "javascript", "c8ecd0c3-693e-4026-8698-8185e20c4d0b" => "bash", "f943f133-cb73-4b47-a5c3-0078712906bf" => "json", "93cf8228-2aae-4691-8525-f6e37cecd189" => "yaml", "34d06144-5932-47b0-b00d-3e55b9ea8d19" => "markdown", "e46af71d-21f2-49b5-bc02-4b5021fd0681" => "zsh", "1f8a754f-e5ec-418b-b4b2-92bd24aa8798" => "shell", "0af3ccde-f9c3-409b-8171-2f01db9b869e" => "shell", "cddeb072-f13d-4b7b-94a9-1a1e8c480775" => "bash", "51c77565-8545-4ce7-86b9-476c962a7376" => "bash", "64cb2e46-129e-44b5-af47-deb67bc4870b" => "json", "12f44507-75ae-48a1-aada-77c75e9b4814" => "graphql", "d16236f4-5d05-4fc5-8465-4df119a7d0b3" => "graphql" }.freeze ANALYSIS_LANGUAGE_MAP = { "ASP" => "asp", "BatchFile" => "batch", "CSS" => "css", "CoffeeScript" => "coffeescript", "Elixir" => "elixir", "Groovy" => "groovy", "HTML" => "html", "JSON" => "json", "JavaScript" => "javascript", "Lua" => "lua", "Markdown" => "markdown", "Perl" => "perl", "Python" => "python", "R" => "r", "Ruby" => "ruby", "SQL" => "sql", "Shell" => "shell", "TEX" => "tex", "TOML" => "toml", "TypeScript" => "typescript", "YAML" => "yaml" }.freeze CLASSIFICATION_LANGUAGE_MAP = { "asp" => "asp", "bat" => "batch", "bash" => "bash", "coffee" => "coffeescript", "css" => "css", "graphql" => "graphql", "groovy" => "groovy", "html" => "html", "js" => "javascript", "json" => "json", "lua" => "lua", "md" => "markdown", "pl" => "perl", "py" => "python", "r" => "r", "rb" => "ruby", "sh" => "shell", "sql" => "sql", "tex" => "tex", "toml" => "toml", "ts" => "typescript", "yaml" => "yaml", "yml" => "yaml", "zsh" => "zsh" }.freeze FENCE_LANGUAGE_MAP = { "applescript" => "applescript", "asp" => "asp", "bash" => "bash", "batch" => "batch", "coffeescript" => "coffeescript", "css" => "css", "elixir" => "elixir", "graphql" => "graphql", "groovy" => "groovy", "html" => "html", "javascript" => "javascript", "json" => "json", "lua" => "lua", "markdown" => "markdown", "perl" => "perl", "python" => "python", "r" => "r", "ruby" => "ruby", "shell" => "bash", "sql" => "sql", "tex" => "tex", "toml" => "toml", "typescript" => "typescript", "yaml" => "yaml", "zsh" => "zsh" }.freeze KNOWN_CONTROL_CHARACTERS = { "c379d282-ca8b-493e-a4df-fa010ebc3797" => [0x07, 0x1B] }.freeze options = { input: "./tmp/pieces-assets.json", output: "./snippets", force: false } OptionParser.new do |parser| parser.banner = "Usage: ruby migrate_pieces_to_obsidian.rb [options]" parser.on("-i", "--input PATH", "Pieces assets.json snapshot") do |path| options[:input] = path end parser.on("-o", "--output PATH", "Output directory") do |path| options[:output] = path end parser.on("-f", "--force", "Replace an existing output directory") do options[:force] = true end end.parse! input_path = File.expand_path(options[:input]) output_path = File.expand_path(options[:output]) abort "Input does not exist: #{input_path}" unless File.file?(input_path) if File.exist?(output_path) && !options[:force] abort <<~MESSAGE Output already exists: #{output_path} Re-run with --force to replace it. MESSAGE end def extract_body(asset) raw = asset.dig("original", "reference", "fragment", "string", "raw") if raw.is_a?(String) return [raw.dup.force_encoding(Encoding::UTF_8), "string"] end bytes = asset.dig("original", "reference", "file", "bytes", "raw") if bytes.is_a?(Array) unless bytes.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) } raise "Invalid byte array for asset #{asset.fetch("id")}" end return [ bytes.pack("C*").force_encoding(Encoding::UTF_8), "file_bytes" ] end raise "Unsupported body storage for asset #{asset.fetch("id")}" end def validate_body!(asset, body) id = asset.fetch("id") raise "Invalid UTF-8 in asset #{id}" unless body.valid_encoding? if body.bytes.first(3) == [0xEF, 0xBB, 0xBF] raise "Unexpected UTF-8 BOM in asset #{id}" end controls = body.codepoints.select do |codepoint| (codepoint < 0x20 && ![0x09, 0x0A, 0x0D].include?(codepoint)) || codepoint == 0x7F end allowed = KNOWN_CONTROL_CHARACTERS.fetch(id, []) unexpected = controls.uniq - allowed return if unexpected.empty? formatted = unexpected.map { |cp| "U+%04X" % cp }.join(", ") raise "Unexpected control characters in asset #{id}: #{formatted}" end def source_timestamp(asset, key) value = asset.dig(key, "value") unless value.is_a?(String) raise "Missing #{key}.value for asset #{asset.fetch("id")}" end Time.iso8601(value) value end def analysis_language(asset) asset.dig("original", "reference", "analysis", "code", "language") || asset.dig("original", "analysis", "code", "language") || asset.dig("analysis", "code", "language") end def classification_language(asset) asset.dig("original", "reference", "classification", "specific") || asset.dig("original", "classification", "specific") || asset.dig("classification", "specific") end def shell_like?(body) patterns = [ /\A\s*#!/, /\b(?:mdfind|mdls|defaults|osascript|plutil|brew|git|gh|curl|wget|grep|sed|awk|find|fd|fzf|xargs|open|launchctl|pg_ctl)\b/, /\b(?:if|then|elif|fi|for|while|until|do|done|case|esac|function)\b/, /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/, /(?:&&|\|\|)/, /\|\s*[A-Za-z_][A-Za-z0-9_-]*/ ] patterns.any? { |pattern| body.match?(pattern) } end def normalize_language(asset, language) case language when "asp" "markdown" when "groovy" "graphql" when "tex" "shell" when "lua" asset.fetch("id") == REAL_LUA_ASSET_ID ? "lua" : "applescript" else language end end def resolve_language(asset, body) id = asset.fetch("id") if LANGUAGE_OVERRIDES.key?(id) return LANGUAGE_OVERRIDES.fetch(id) end analysis = analysis_language(asset) specific = classification_language(asset) detected = if analysis.is_a?(String) && !analysis.empty? && !%w[Unknown UNKNOWN TEXT text].include?(analysis) ANALYSIS_LANGUAGE_MAP[analysis] end if detected.nil? && specific.is_a?(String) && !specific.empty? && !%w[Unknown UNKNOWN TEXT text].include?(specific) detected = CLASSIFICATION_LANGUAGE_MAP[specific.downcase] end # Pieces sometimes identifies Unix shell snippets as Windows BatchFile. if detected == "batch" && shell_like?(body) detected = "shell" end normalize_language(asset, detected) end def manual_tags(asset) id = asset.fetch("id") Array(asset.dig("tags", "iterable")) .select do |tag| text = tag["text"] text.is_a?(String) && !text.strip.empty? && tag.dig("mechanisms", id) == "MANUAL" end .map { |tag| tag.fetch("text").strip } .uniq end def manual_explanation(asset) annotations = Array(asset.dig("annotations", "iterable")) candidates = annotations.select do |annotation| annotation["type"] == "EXPLANATION" && annotation["mechanism"] == "MANUAL" && annotation["text"].is_a?(String) && !annotation["text"].strip.empty? end candidates.max_by do |annotation| annotation.dig("updated", "value") || annotation.dig("created", "value") || "" end&.fetch("text")&.strip end def accepted_source_url(asset) id = asset.fetch("id") Array(asset.dig("websites", "iterable")) .find do |website| url = website["url"].to_s website.dig("mechanisms", id) == "MANUAL" && website["name"] == "Origin Website" && !url.empty? && !url.include?("cache.raindrop.io") && !url.include?("X-Amz-Signature=") && !url.include?("X-Amz-Expires=") && !url.include?("/_next/static/chunks/") end &.fetch("url") end def sanitize_filename(name) value = name.to_s.unicode_normalize(:nfc) value = value # macOS / path separators plus characters Obsidian disallows. .gsub(/[\/:\[\]#^|]+/, " - ") .gsub(/[\u0000-\u001F\u007F]/, " ") .gsub(/\s+-\s+-\s+/, " - ") .gsub(/\s+/, " ") .strip .sub(/\A[.\s]+/, "") .sub(/[.\s]+\z/, "") value = "Untitled" if value.empty? value end def collision_key(filename) filename .unicode_normalize(:nfc) .downcase end def safe_fence(body) max_run = body.scan(/`+/).map(&:length).max || 0 "`" * [3, max_run + 1].max end def yaml_string(value) JSON.generate(value) end def frontmatter( language:, tags:, source_url:, created_at:, updated_at: ) output = +"---\n" output << "type: snippet\n" output << %(source: "[[Pieces.app]]"\n) output << "language: #{yaml_string(language)}\n" if language output << "created_on: #{Date.parse(created_at)}\n" output << "updated_on: #{Date.parse(updated_at)}\n" unless tags.empty? output << "tags:\n" tags.each do |tag| output << " - #{yaml_string(tag)}\n" end end if source_url output << "source_url: #{yaml_string(source_url)}\n" end output << "---\n" output end def markdown_for( body:, language:, tags:, source_url:, explanation:, created_at:, updated_at: ) markdown = +"" markdown << frontmatter( language: language, tags: tags, source_url: source_url, created_at: created_at, updated_at: updated_at ) if explanation markdown << "\n## Explanation\n\n" markdown << explanation markdown << "\n" end markdown << "\n## Code\n\n" fence = safe_fence(body) fence_language = FENCE_LANGUAGE_MAP.fetch(language, language).to_s markdown << fence markdown << fence_language unless fence_language.empty? markdown << "\n" markdown << body # The closing Markdown fence must begin on its own line. The manifest # retains whether the original Pieces body actually had a trailing newline. markdown << "\n" unless body.end_with?("\n") markdown << fence markdown << "\n" markdown end raw_json = File.binread(input_path) raw_sha256 = Digest::SHA256.hexdigest(raw_json) data = JSON.parse(raw_json) assets = data.fetch("iterable") unless assets.length == EXPECTED_ASSET_COUNT abort( "Expected #{EXPECTED_ASSET_COUNT} assets from the audited snapshot, " \ "but found #{assets.length}. Re-audit before exporting." ) end asset_ids = assets.map { |asset| asset.fetch("id") } unless asset_ids.uniq.length == asset_ids.length abort "Duplicate Pieces asset IDs detected." end original_ids = assets.map { |asset| asset.dig("original", "id") } unless original_ids.compact.uniq.length == original_ids.compact.length abort "Duplicate Pieces original IDs detected." end prepared = assets.map do |asset| body, storage = extract_body(asset) validate_body!(asset, body) created_at = source_timestamp(asset, "created") updated_at = source_timestamp(asset, "updated") if Time.iso8601(updated_at) < Time.iso8601(created_at) raise "updated < created for asset #{asset.fetch("id")}" end { asset: asset, id: asset.fetch("id"), original_id: asset.dig("original", "id"), name: asset.fetch("name"), basename: sanitize_filename(asset.fetch("name")), body: body, body_storage: storage, body_sha256: Digest::SHA256.hexdigest(body.b), body_bytes: body.bytesize, trailing_newline: body.end_with?("\n"), language: resolve_language(asset, body), raw_analysis_language: analysis_language(asset), raw_classification_language: classification_language(asset), created_at: created_at, updated_at: updated_at, tags: manual_tags(asset), explanation: manual_explanation(asset), source_url: accepted_source_url(asset) } end groups = prepared.group_by do |record| collision_key(record.fetch(:basename)) end prepared.each do |record| colliding = groups.fetch( collision_key(record.fetch(:basename)) ).length > 1 record[:filename] = if colliding "#{record.fetch(:basename)}--#{record.fetch(:id)[0, 8]}.md" else "#{record.fetch(:basename)}.md" end end filenames = prepared.map do |record| collision_key(record.fetch(:filename)) end unless filenames.uniq.length == filenames.length abort "Filename collision remains after deterministic ID suffixing." end temporary_path = "#{output_path}.tmp-#{Process.pid}-#{SecureRandom.hex(4)}" begin FileUtils.rm_rf(temporary_path) raw_dir = File.join(temporary_path, "raw") normalized_dir = File.join(temporary_path, "normalized") markdown_dir = File.join(temporary_path, "markdown") FileUtils.mkdir_p(raw_dir) FileUtils.mkdir_p(normalized_dir) FileUtils.mkdir_p(markdown_dir) raw_output = File.join(raw_dir, "assets.json") File.binwrite(raw_output, raw_json) File.chmod(0o600, raw_output) normalized_path = File.join(normalized_dir, "assets.jsonl") File.open(normalized_path, "wb") do |file| prepared.sort_by { |record| record.fetch(:id) }.each do |record| asset = record.fetch(:asset) normalized = { id: record.fetch(:id), original_id: record[:original_id], name: record.fetch(:name), filename: record.fetch(:filename), body: record.fetch(:body), body_storage: record.fetch(:body_storage), body_sha256: record.fetch(:body_sha256), body_bytes: record.fetch(:body_bytes), trailing_newline: record.fetch(:trailing_newline), language: { resolved: record[:language], pieces_analysis: record[:raw_analysis_language], pieces_classification: record[:raw_classification_language] }, timestamps: { created: record.fetch(:created_at), updated: record.fetch(:updated_at) }, markdown_metadata: { tags: record.fetch(:tags), source_url: record[:source_url], explanation: record[:explanation] }, annotations: Array(asset.dig("annotations", "iterable")), tags: Array(asset.dig("tags", "iterable")), websites: Array(asset.dig("websites", "iterable")) } file.write(JSON.generate(normalized)) file.write("\n") end end manifest_assets = [] prepared .sort_by { |record| record.fetch(:filename).downcase } .each do |record| markdown = markdown_for( body: record.fetch(:body), language: record[:language], tags: record.fetch(:tags), source_url: record[:source_url], explanation: record[:explanation], created_at: record.fetch(:created_at), updated_at: record.fetch(:updated_at) ) markdown_path = File.join(markdown_dir, record.fetch(:filename)) File.binwrite(markdown_path, markdown) manifest_assets << { id: record.fetch(:id), original_id: record[:original_id], name: record.fetch(:name), filename: record.fetch(:filename), body_storage: record.fetch(:body_storage), body_sha256: record.fetch(:body_sha256), body_bytes: record.fetch(:body_bytes), trailing_newline: record.fetch(:trailing_newline), markdown_sha256: Digest::SHA256.hexdigest(markdown.b), language: record[:language], pieces_analysis_language: record[:raw_analysis_language], pieces_classification_language: record[:raw_classification_language], created: record.fetch(:created_at), updated: record.fetch(:updated_at), manual_tags: record.fetch(:tags), explanation: record[:explanation], source_url: record[:source_url] } end markdown_count = Dir.glob(File.join(markdown_dir, "*.md")).length unless markdown_count == assets.length raise( "Expected #{assets.length} Markdown files, " \ "created #{markdown_count}" ) end language_counts = prepared .group_by { |record| record[:language] || "unknown" } .transform_values(&:length) .sort .to_h manifest = { schema_version: 2, generated_at: Time.now.utc.iso8601, source: { path: input_path, sha256: raw_sha256, asset_count: assets.length }, output: { markdown_count: markdown_count, assets_with_manual_tags: prepared.count { |record| record.fetch(:tags).any? }, total_manual_tags: prepared.sum { |record| record.fetch(:tags).length }, assets_with_explanation: prepared.count { |record| record[:explanation] }, assets_with_source_url: prepared.count { |record| record[:source_url] }, languages: language_counts }, assets: manifest_assets } File.write( File.join(temporary_path, "manifest.json"), JSON.pretty_generate(manifest) + "\n" ) FileUtils.rm_rf(output_path) if File.exist?(output_path) FileUtils.mv(temporary_path, output_path) puts puts "Pieces export complete" puts "----------------------" puts "Assets: #{assets.length}" puts "Markdown files: #{markdown_count}" puts "Manual tags: #{manifest.dig(:output, :total_manual_tags)}" puts "Explanations: #{manifest.dig(:output, :assets_with_explanation)}" puts "Source URLs: #{manifest.dig(:output, :assets_with_source_url)}" puts "Raw SHA-256: #{raw_sha256}" puts "Output: #{output_path}" puts puts "Languages:" language_counts.each do |language, count| puts format(" %-16s %d", language, count) end rescue FileUtils.rm_rf(temporary_path) raise end ``` ## Final Thoughts I don't really have a problem with Pieces moving functionality behind a paid plan. Software costs money to build and services cost money to run. It just changed the equation for me. I primarily wanted a convenient place to save code snippets, and at this point I would rather own those snippets as Markdown than continue storing them in another application-specific system. I had to make a few changes after running the migration script, but that's fine. This is also a good reminder of something I keep relearning with personal tooling: **the easier it is to get your data out of a tool, the more comfortable I am putting data into it.** Pieces gave me enough local access to recover everything I cared about, and now my snippets are sitting alongside the rest of my notes in Obsidian. With the migration verified, I can finally uninstall Pieces without wondering what I left behind.