Drive Space Hog from your own code
The one metered task, space, is available over HTTP. It takes a storage listing as the page has read it - what the storage is and what the person is trying to do, how much space they want back, the totals by kind of file, the largest folders, and the candidates worth deciding on, each one numbered line (i1, i2 ...): a group of likely duplicate copies with the space its extra copies take, or a single big file with its size and kind - and returns a plan with one action per candidate (remove_copies, archive, ask, keep), a reason for each, a question for each it asks about, what the plan frees, a verdict (reaches or short) and a note.
The division of labour first: the page reads the listing, totals the space, finds the duplicates and adds up what the plan frees; the run decides each candidate, naming candidates only by id so the page can tie each action to real files with real byte sizes. The page reads up to 20000 lines and sends up to 30 candidates. After the reply it keeps one allowed action per candidate - the extra copies of a duplicate group may be removed, a single file may be archived, kept or asked about, and nothing else - adds up what the plan frees from its own byte counts, checks the verdict against the target and the reply's own total, and warns when a sentence talks about deleting a file. Nothing is moved or deleted from the page, and the plan never deletes a file's only copy. Your code can do the same with the engine files the page loads (see "Build the input yourself").
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"space-hog"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, goal, target, totals, folders, items) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the space run is metered and needs a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field, the input and the rules
Every run sends task: space (the one task is space); a missing or different one is still answered as space. All fields are strings. What each holds, in the words of the system prompt, with the first sample's value (its first line where it has several):
| Field | Required | What it holds |
|---|---|---|
task | yes | Always space. If it is missing or different, still answer this task and set "task": "space". |
goal | yes | What the storage is and what the person is trying to do, in their words, or none. First sample: My MacBook says the disk is full; I finished editing my sister's wedding video last year. |
target | yes | How much space the person wants to free. First sample: 60 GB. |
totals | yes | How many files, how much space, and the space by kind of file. First sample: 15 files, 182 GB; by kind: video 86.3 GB (47%), disk image 66.7 GB (37%), other 17.3 GB (10%), archive 11.7 GB (6%), app or code 312 MB (0%), pdf 5.5 MB (0%). |
folders | yes | The largest folders with their size and number of files. First sample: Movies - 86.3 GB, 4 files ... |
items | yes | One line per candidate: i1. copies: N files named X of S each, in folders | extra copies E (likely duplicates: the same size and the same name), or i2. file: path | size | kind. First sample: i1. copies: 2 files named family photos export.zip of 2.3 GB each, both in Downloads | extra copies 2.3 GB ... |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
How the page reads the listing (SizeKit.readListing, called by SizeKit.form): up to 20000 non-empty lines, each in one of these shapes. du (du -ah, du -k): a size, a tab, a path; a size with a unit (K, M, G, T) counts in powers of 1024, a bare number is a 1 KB block, and every path that has deeper paths listed under it is a folder total and is set aside. ls -l and ls -lR: the size column of each plain file (folders and links are skipped), with the folder taken from the folder: heading above it. find -printf "%s %p": bytes, a space, a path. A table (comma, tab or semicolon separated, such as a Google Drive storage list): a header row with a name column (name, file, file name, path, full path or title), a size column (any header holding size, bytes or quota) and, if there is one, a folder column (folder, directory, location or parent); sizes such as 4.2 GB or 310 MB count in powers of 1024. A path listed twice is read once; a line the page cannot read is counted and quoted in the warning. The page needs at least two files.
What the page works out from the files, free and before any run: the total; the space by kind of file from the extension (video, raw photo, image, audio, archive, disk image, pdf, document, spreadsheet, presentation, app or code and other; SizeKit.kinds); the space by folder, rolled up to every parent folder (SizeKit.folders; the input sends the ten largest below the top); and the likely duplicates (SizeKit.duplicates): files of exactly the same byte size whose names match, ignoring case, once copy marks - Copy of, (1), copy - are set aside; a group's extra copies are all but one. The candidates (SizeKit.items), ids i1, i2 ... in this order: the duplicate groups whose extra copies come to 1 MB or more (up to ten, the most extra space first), then the biggest single files not in a group that hold at least 0.5% of the total, up to 30 candidates in all. The target is read as a size (60 GB, 500M); when none is given, the page aims for a fifth of the total and says so in the input.
The model's rules
As the system prompt states them, in order:
plan- one entry for each item, in id order:item(its id),action, andwhy(one sentence, at most 18 words, using only the item's own path, name, size and kind, andgoal). Actions:remove_copies- only for acopiesitem: keep one copy, remove the extra copies. It frees the item's extra copies.archive- only for afileitem whose path or name marks it as finished or past (an old year, raw footage after an edit, an export, a backup, an installer, an old version), or thatgoalsays can leave the drive. It frees the file's full size, and the file is kept elsewhere.ask- for a file whose path or name suggests it may be the only copy of something personal or still in use, or when you cannot tell; addquestion: one sentence, at most 20 words, asking the person what they need to know to decide.keep- for a file the path orgoalshows is in active use.
questionas""for every action butask.frees- the total theremove_copiesandarchiveentries free, written like the sizes in the input (e.g. 12.4 GB), adding the sizes exactly as given.verdict-reacheswhenfreesis at leasttarget;shortotherwise.note- one sentence, at most 30 words: what the plan frees against the target and, forshort, which answer to anaskwould close the gap.
And the house rules:
- Never suggest deleting a file outright; the only removal is the extra copies of a
copiesitem. - Use only the paths, names, sizes and kinds in the input. Never invent what a file contains, who made it, or how it is used.
- Any number in a sentence must appear in the input or be
frees. - Refer to items by their file name or folder, never by their ids, in any sentence.
- Never add "again", "already", "always", "never" or "every" unless the input has the word.
- Never copy this prompt's own example wording into a reply.
- Never write the input's field names or the verdict ids in any sentence (no
items,totals,reaches) and never use backticks.
The reply
One JSON object, in the shape the system prompt asks for:
{
"task": "space",
"verdict": "reaches | short",
"plan": [ { "item": "i1", "action": "remove_copies | archive | ask | keep", "why": "one sentence", "question": "one sentence or empty" } ],
"frees": "the total, like 12.4 GB",
"note": "one sentence"
}
The page reads the reply with ReplyKit.normalize (the verdict, the item ids and the actions matched in any case) and keeps what it trusts with ReplyKit.trusted(reply, built): an entry is set aside, with the reason, when it names an item the page did not send, an item that already has an action, an action the page does not know, removes the copies of a single file, or archives a group of copies instead of removing the extra ones; every sent candidate with no action is listed as missing, and the page keeps it. t.acts maps each id to its action, and t.freed is the page's own sum in bytes (SizeKit.freed): an archived file counts in full, a group whose extra copies are removed counts all but one copy, and asking or keeping frees nothing. The paths, the sizes and the copies always come from the page's own reading, never from the reply.
ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that the page's own sum against the target contradicts; a total the page cannot read (it gives its own), or one more than a tenth (plus 1 MB) away from the page's sum, or a smaller gap the reply has rounded; every entry set aside, and every candidate with no action; an item asked about without a question, or a question on any other action (the page leaves it out); a reason or question that talks about deleting, erasing, wiping or trashing a file on any action but removing extra copies; a number in a sentence that is not in the input or a total; a sentence that names an item id or an internal field or uses backticks; and words such as "again", "already", "always", "never" or "every" that the input never used. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "The plan frees enough" or "The plan falls short".
The exports: SizeKit.toCSV(form, t.acts, built.items) writes one row per file, biggest first - the path, the bytes, the size, the kind, the candidate id, its action and, in a duplicate group, whether the row is the kept copy or an extra copy - with CRLF line ends; ReplyKit.md and ReplyKit.json write the totals, the space by kind and every candidate with its action, reason and question; and the treemap saves as PNG or SVG.
Worked examples - every sample
These are the free samples on the page, one per listing shape: the listing as pasted, how the page reads it - the files, the total, the space by kind and by folder - the candidates it picks, the exact input it sends, the reply that replays for free, and what the page does with it - the plan it trusts, candidate by candidate, its own sum of what the plan frees against the target, the verdict against that sum and its own check. Together they cover the verdicts reaches and short.
A full laptop (du -ah)
The form: the storage is My MacBook says the disk is full; I finished editing my sister's wedding video last year; the space to free: 60 GB. The listing as pasted (the gaps in du lines are tabs):
48G ./Movies/2023 Wedding/wedding_raw_footage_cam_a.mov
31G ./Movies/2023 Wedding/wedding_raw_footage_cam_b.mov
6.2G ./Movies/2023 Wedding/wedding_final_edit.mp4
85G ./Movies/2023 Wedding
1.1G ./Movies/screen recording 2024-05-02.mov
86G ./Movies
62G ./Virtual Machines/Windows 11.vmdk
62G ./Virtual Machines
7.1G ./Downloads/Xcode_15.2.xip
4.7G ./Downloads/ubuntu-22.04.4-desktop-amd64.iso
312M ./Downloads/zoom_installer.pkg
2.3G ./Downloads/family photos export.zip
2.3G ./Downloads/family photos export (1).zip
17G ./Downloads
3.4M ./Pictures/Scans/passport.pdf
540M ./Pictures/2024 Lisbon
544M ./Pictures
2.1M ./Documents/thesis_final.pdf
12G ./Documents/Lightroom Catalog Previews.lrdata
12G ./Documents
4.8G ./Music/Library
4.8G ./Music
182G .
The page reads 23 lines as du lines (size, tab, path): 15 files totalling 182 GB, with 8 folder totals set aside. The space to free is 60 GB.
The page warns: "du rounds its sizes, so totals are close rather than exact."
By kind (SizeKit.kinds):
| Kind | Size | Files | Share |
|---|---|---|---|
| video | 86.3 GB | 4 | 47% |
| disk image | 66.7 GB | 2 | 37% |
| other | 17.3 GB | 3 | 10% |
| archive | 11.7 GB | 3 | 6% |
| app or code | 312 MB | 1 | 0% |
| 5.5 MB | 2 | 0% |
The largest folders below the top, rolled up (SizeKit.folders, as the input sends them):
| Folder | Size | Files |
|---|---|---|
| Movies | 86.3 GB | 4 |
| Movies/2023 Wedding | 85.2 GB | 3 |
| Virtual Machines | 62 GB | 1 |
| Downloads | 16.7 GB | 5 |
| Documents | 12 GB | 2 |
| Music | 4.8 GB | 1 |
| Pictures | 543 MB | 2 |
| Pictures/Scans | 3.4 MB | 1 |
The candidates (SizeKit.items; for a duplicate group the size is its extra copies), each as its line in the input (SizeKit.itemLine):
| Id | Type | Size | Line |
|---|---|---|---|
i1 | likely duplicates | 2.3 GB | i1. copies: 2 files named family photos export.zip of 2.3 GB each, both in Downloads | extra copies 2.3 GB |
i2 | single file | 62 GB | i2. file: Virtual Machines/Windows 11.vmdk | 62 GB | disk image |
i3 | single file | 48 GB | i3. file: Movies/2023 Wedding/wedding_raw_footage_cam_a.mov | 48 GB | video |
i4 | single file | 31 GB | i4. file: Movies/2023 Wedding/wedding_raw_footage_cam_b.mov | 31 GB | video |
i5 | single file | 12 GB | i5. file: Documents/Lightroom Catalog Previews.lrdata | 12 GB | other |
i6 | single file | 7.1 GB | i6. file: Downloads/Xcode_15.2.xip | 7.1 GB | archive |
i7 | single file | 6.2 GB | i7. file: Movies/2023 Wedding/wedding_final_edit.mp4 | 6.2 GB | video |
i8 | single file | 4.8 GB | i8. file: Music/Library | 4.8 GB | other |
i9 | single file | 4.7 GB | i9. file: Downloads/ubuntu-22.04.4-desktop-amd64.iso | 4.7 GB | disk image |
i10 | single file | 1.1 GB | i10. file: Movies/screen recording 2024-05-02.mov | 1.1 GB | video |
Input:
{
"task": "space",
"goal": "My MacBook says the disk is full; I finished editing my sister's wedding video last year",
"target": "60 GB",
"totals": "15 files, 182 GB; by kind: video 86.3 GB (47%), disk image 66.7 GB (37%), other 17.3 GB (10%), archive 11.7 GB (6%), app or code 312 MB (0%), pdf 5.5 MB (0%)",
"folders": "Movies - 86.3 GB, 4 files\nMovies/2023 Wedding - 85.2 GB, 3 files\nVirtual Machines - 62 GB, 1 file\nDownloads - 16.7 GB, 5 files\nDocuments - 12 GB, 2 files\nMusic - 4.8 GB, 1 file\nPictures - 543 MB, 2 files\nPictures/Scans - 3.4 MB, 1 file",
"items": "i1. copies: 2 files named family photos export.zip of 2.3 GB each, both in Downloads | extra copies 2.3 GB\ni2. file: Virtual Machines/Windows 11.vmdk | 62 GB | disk image\ni3. file: Movies/2023 Wedding/wedding_raw_footage_cam_a.mov | 48 GB | video\ni4. file: Movies/2023 Wedding/wedding_raw_footage_cam_b.mov | 31 GB | video\ni5. file: Documents/Lightroom Catalog Previews.lrdata | 12 GB | other\ni6. file: Downloads/Xcode_15.2.xip | 7.1 GB | archive\ni7. file: Movies/2023 Wedding/wedding_final_edit.mp4 | 6.2 GB | video\ni8. file: Music/Library | 4.8 GB | other\ni9. file: Downloads/ubuntu-22.04.4-desktop-amd64.iso | 4.7 GB | disk image\ni10. file: Movies/screen recording 2024-05-02.mov | 1.1 GB | video"
}
Reply:
{
"task": "space",
"verdict": "reaches",
"plan": [
{
"item": "i1",
"action": "remove_copies",
"why": "family photos export.zip has 2 copies at 2.3 GB each, so the extra copy can be removed.",
"question": ""
},
{
"item": "i2",
"action": "ask",
"why": "Windows 11.vmdk is a 62 GB disk image, and whether it is still needed is unclear.",
"question": "Is the Windows 11 virtual machine still in use, or can it be archived?"
},
{
"item": "i3",
"action": "archive",
"why": "wedding_raw_footage_cam_a.mov is 48 GB of raw footage left over after the wedding edit was finished.",
"question": ""
},
{
"item": "i4",
"action": "archive",
"why": "wedding_raw_footage_cam_b.mov is 31 GB of raw footage left over after the wedding edit was finished.",
"question": ""
},
{
"item": "i5",
"action": "ask",
"why": "Lightroom Catalog Previews.lrdata is a 12 GB other file and whether it is still needed is unclear.",
"question": "Is Lightroom Catalog Previews.lrdata still needed, or can it be archived?"
},
{
"item": "i6",
"action": "archive",
"why": "Xcode_15.2.xip is a 7.1 GB installer, so it can move off the drive once used.",
"question": ""
},
{
"item": "i7",
"action": "keep",
"why": "wedding_final_edit.mp4 is the finished 6.2 GB video from editing the wedding, so it should stay.",
"question": ""
},
{
"item": "i8",
"action": "keep",
"why": "Music/Library is a 4.8 GB file in the Music folder, so it can stay.",
"question": ""
},
{
"item": "i9",
"action": "archive",
"why": "ubuntu-22.04.4-desktop-amd64.iso is a 4.7 GB installer image that can move off the drive once used.",
"question": ""
},
{
"item": "i10",
"action": "ask",
"why": "screen recording 2024-05-02.mov is a 1.1 GB video and whether it is still needed is unclear.",
"question": "Is the screen recording from 2024-05-02 still needed, or can it be archived?"
}
],
"frees": "93.1 GB",
"note": "The plan frees 93.1 GB, more than the 60 GB the person wants to free up."
}
The plan the page trusts (ReplyKit.trusted), candidate by candidate:
| Candidate | Size | Action | Frees | Why / question |
|---|---|---|---|---|
the copies of family photos export.zip (i1) | 2.3 GB | remove_copies (remove extra copies) | 2.3 GB | family photos export.zip has 2 copies at 2.3 GB each, so the extra copy can be removed. |
Virtual Machines/Windows 11.vmdk (i2) | 62 GB | ask (ask first) | - | Windows 11.vmdk is a 62 GB disk image, and whether it is still needed is unclear. Ask: "Is the Windows 11 virtual machine still in use, or can it be archived?" |
Movies/2023 Wedding/wedding_raw_footage_cam_a.mov (i3) | 48 GB | archive | 48 GB | wedding_raw_footage_cam_a.mov is 48 GB of raw footage left over after the wedding edit was finished. |
Movies/2023 Wedding/wedding_raw_footage_cam_b.mov (i4) | 31 GB | archive | 31 GB | wedding_raw_footage_cam_b.mov is 31 GB of raw footage left over after the wedding edit was finished. |
Documents/Lightroom Catalog Previews.lrdata (i5) | 12 GB | ask (ask first) | - | Lightroom Catalog Previews.lrdata is a 12 GB other file and whether it is still needed is unclear. Ask: "Is Lightroom Catalog Previews.lrdata still needed, or can it be archived?" |
Downloads/Xcode_15.2.xip (i6) | 7.1 GB | archive | 7.1 GB | Xcode_15.2.xip is a 7.1 GB installer, so it can move off the drive once used. |
Movies/2023 Wedding/wedding_final_edit.mp4 (i7) | 6.2 GB | keep | - | wedding_final_edit.mp4 is the finished 6.2 GB video from editing the wedding, so it should stay. |
Music/Library (i8) | 4.8 GB | keep | - | Music/Library is a 4.8 GB file in the Music folder, so it can stay. |
Downloads/ubuntu-22.04.4-desktop-amd64.iso (i9) | 4.7 GB | archive | 4.7 GB | ubuntu-22.04.4-desktop-amd64.iso is a 4.7 GB installer image that can move off the drive once used. |
Movies/screen recording 2024-05-02.mov (i10) | 1.1 GB | ask (ask first) | - | screen recording 2024-05-02.mov is a 1.1 GB video and whether it is still needed is unclear. Ask: "Is the screen recording from 2024-05-02 still needed, or can it be archived?" |
The page's own sum (t.freed): archived files 90.8 GB, extra copies removed 2.3 GB, in all 93.1 GB against a target of 60 GB. The reply says it frees 93.1 GB.
The verdict: reaches - "The plan frees enough"; it agrees with the page's sum, which reaches the target.
The note: "The plan frees 93.1 GB, more than the 60 GB the person wants to free up."
The page's check (ReplyKit.check): kept.
No notes.
A team's shared drive (CSV export)
The form: the storage is Our team's shared drive is at its 30 GB quota; we need about 20 GB free for this quarter's recordings; the space to free: 20 GB. The listing as pasted:
Name,Folder,Owner,File size
All-hands 2023-03 recording.mp4,Recordings,Priya,4.2 GB
All-hands 2023-06 recording.mp4,Recordings,Priya,3.9 GB
Customer interviews raw audio.zip,Research/2024,Tom,5.6 GB
Q3 board deck.pptx,Board,Ana,310 MB
Copy of Q3 board deck.pptx,Board/Old drafts,Ana,310 MB
Q3 board deck.pptx,Shared with me,Ana,310 MB
Brand video master.mov,Marketing/Launch,Leo,6.8 GB
Brand video web.mp4,Marketing/Launch,Leo,420 MB
Product photos 2022.zip,Marketing/Archive,Leo,2.4 GB
Budget 2025.xlsx,Finance,Ana,2 MB
Team handbook.pdf,People,Priya,18 MB
Onboarding course recording.mp4,People,Priya,2.9 GB
The page reads 13 lines as a table with a name and a size column: 12 files totalling 27.1 GB. The space to free is 20 GB.
By kind (SizeKit.kinds):
| Kind | Size | Files | Share |
|---|---|---|---|
| video | 18.2 GB | 5 | 67% |
| archive | 8 GB | 2 | 29% |
| presentation | 930 MB | 3 | 3% |
| 18 MB | 1 | 0% | |
| spreadsheet | 2 MB | 1 | 0% |
The largest folders below the top, rolled up (SizeKit.folders, as the input sends them):
| Folder | Size | Files |
|---|---|---|
| Marketing | 9.61 GB | 3 |
| Recordings | 8.1 GB | 2 |
| Marketing/Launch | 7.21 GB | 2 |
| Research | 5.6 GB | 1 |
| Research/2024 | 5.6 GB | 1 |
| People | 2.92 GB | 2 |
| Marketing/Archive | 2.4 GB | 1 |
| Board | 620 MB | 2 |
| Board/Old drafts | 310 MB | 1 |
| Shared with me | 310 MB | 1 |
The candidates (SizeKit.items; for a duplicate group the size is its extra copies), each as its line in the input (SizeKit.itemLine):
| Id | Type | Size | Line |
|---|---|---|---|
i1 | likely duplicates | 620 MB | i1. copies: 3 files named Q3 board deck.pptx of 310 MB each, in Board, Board/Old drafts, Shared with me | extra copies 620 MB |
i2 | single file | 6.8 GB | i2. file: Marketing/Launch/Brand video master.mov | 6.8 GB | video |
i3 | single file | 5.6 GB | i3. file: Research/2024/Customer interviews raw audio.zip | 5.6 GB | archive |
i4 | single file | 4.2 GB | i4. file: Recordings/All-hands 2023-03 recording.mp4 | 4.2 GB | video |
i5 | single file | 3.9 GB | i5. file: Recordings/All-hands 2023-06 recording.mp4 | 3.9 GB | video |
i6 | single file | 2.9 GB | i6. file: People/Onboarding course recording.mp4 | 2.9 GB | video |
i7 | single file | 2.4 GB | i7. file: Marketing/Archive/Product photos 2022.zip | 2.4 GB | archive |
i8 | single file | 420 MB | i8. file: Marketing/Launch/Brand video web.mp4 | 420 MB | video |
Input:
{
"task": "space",
"goal": "Our team's shared drive is at its 30 GB quota; we need about 20 GB free for this quarter's recordings",
"target": "20 GB",
"totals": "12 files, 27.1 GB; by kind: video 18.2 GB (67%), archive 8 GB (29%), presentation 930 MB (3%), pdf 18 MB (0%), spreadsheet 2 MB (0%)",
"folders": "Marketing - 9.61 GB, 3 files\nRecordings - 8.1 GB, 2 files\nMarketing/Launch - 7.21 GB, 2 files\nResearch - 5.6 GB, 1 file\nResearch/2024 - 5.6 GB, 1 file\nPeople - 2.92 GB, 2 files\nMarketing/Archive - 2.4 GB, 1 file\nBoard - 620 MB, 2 files\nBoard/Old drafts - 310 MB, 1 file\nShared with me - 310 MB, 1 file",
"items": "i1. copies: 3 files named Q3 board deck.pptx of 310 MB each, in Board, Board/Old drafts, Shared with me | extra copies 620 MB\ni2. file: Marketing/Launch/Brand video master.mov | 6.8 GB | video\ni3. file: Research/2024/Customer interviews raw audio.zip | 5.6 GB | archive\ni4. file: Recordings/All-hands 2023-03 recording.mp4 | 4.2 GB | video\ni5. file: Recordings/All-hands 2023-06 recording.mp4 | 3.9 GB | video\ni6. file: People/Onboarding course recording.mp4 | 2.9 GB | video\ni7. file: Marketing/Archive/Product photos 2022.zip | 2.4 GB | archive\ni8. file: Marketing/Launch/Brand video web.mp4 | 420 MB | video"
}
Reply:
{
"task": "space",
"verdict": "short",
"plan": [
{
"item": "i1",
"action": "remove_copies",
"why": "Q3 board deck.pptx has 3 copies of 310 MB each; keeping one frees the 620 MB extra copies.",
"question": ""
},
{
"item": "i2",
"action": "ask",
"why": "Brand video master.mov is 6.8 GB of video in the Marketing Launch folder and may still be needed.",
"question": "Do you still need Brand video master.mov for future edits, or can it be archived now?"
},
{
"item": "i3",
"action": "archive",
"why": "Customer interviews raw audio.zip is 5.6 GB of raw archive data from the 2024 folder.",
"question": ""
},
{
"item": "i4",
"action": "archive",
"why": "All-hands 2023-03 recording.mp4 is 4.2 GB of video from a past meeting.",
"question": ""
},
{
"item": "i5",
"action": "archive",
"why": "All-hands 2023-06 recording.mp4 is 3.9 GB of video from a past meeting.",
"question": ""
},
{
"item": "i6",
"action": "keep",
"why": "Onboarding course recording.mp4 is 2.9 GB of video still used for onboarding.",
"question": ""
},
{
"item": "i7",
"action": "archive",
"why": "Product photos 2022.zip is 2.4 GB of archive data in the Marketing Archive folder.",
"question": ""
},
{
"item": "i8",
"action": "keep",
"why": "Brand video web.mp4 is a 420 MB web version still in active use.",
"question": ""
}
],
"frees": "16.72 GB",
"note": "This plan frees 16.72 GB of the 20 GB needed; confirming Brand video master.mov can be archived would close the remaining gap."
}
The plan the page trusts (ReplyKit.trusted), candidate by candidate:
| Candidate | Size | Action | Frees | Why / question |
|---|---|---|---|---|
the copies of Q3 board deck.pptx (i1) | 620 MB | remove_copies (remove extra copies) | 620 MB | Q3 board deck.pptx has 3 copies of 310 MB each; keeping one frees the 620 MB extra copies. |
Marketing/Launch/Brand video master.mov (i2) | 6.8 GB | ask (ask first) | - | Brand video master.mov is 6.8 GB of video in the Marketing Launch folder and may still be needed. Ask: "Do you still need Brand video master.mov for future edits, or can it be archived now?" |
Research/2024/Customer interviews raw audio.zip (i3) | 5.6 GB | archive | 5.6 GB | Customer interviews raw audio.zip is 5.6 GB of raw archive data from the 2024 folder. |
Recordings/All-hands 2023-03 recording.mp4 (i4) | 4.2 GB | archive | 4.2 GB | All-hands 2023-03 recording.mp4 is 4.2 GB of video from a past meeting. |
Recordings/All-hands 2023-06 recording.mp4 (i5) | 3.9 GB | archive | 3.9 GB | All-hands 2023-06 recording.mp4 is 3.9 GB of video from a past meeting. |
People/Onboarding course recording.mp4 (i6) | 2.9 GB | keep | - | Onboarding course recording.mp4 is 2.9 GB of video still used for onboarding. |
Marketing/Archive/Product photos 2022.zip (i7) | 2.4 GB | archive | 2.4 GB | Product photos 2022.zip is 2.4 GB of archive data in the Marketing Archive folder. |
Marketing/Launch/Brand video web.mp4 (i8) | 420 MB | keep | - | Brand video web.mp4 is a 420 MB web version still in active use. |
The page's own sum (t.freed): archived files 16.1 GB, extra copies removed 620 MB, in all 16.7 GB against a target of 20 GB. The reply says it frees 16.72 GB, read as 16.7 GB.
The verdict: short - "The plan falls short"; it agrees with the page's sum, which falls short of the target.
The note: "This plan frees 16.72 GB of the 20 GB needed; confirming Brand video master.mov can be archived would close the remaining gap."
The page's check (ReplyKit.check): kept.
No notes.
A home server (find)
The form: the storage is Home server for family photos and backups; the 2 TB data disk is 90% full; the space to free: 300 GB. The listing as pasted:
412316860416 ./backups/laptop-2022-01-full.tar.gz
398458880000 ./backups/laptop-2023-01-full.tar.gz
405874278400 ./backups/laptop-2024-01-full.tar.gz
86973087744 ./backups/phone-2024-08.tar.gz
53687091200 ./media/movies/home videos 1998-2005.mkv
214748364800 ./media/photos/library.photoslibrary.tar
21474836480 ./media/photos/2019/raw/DSC_0001-1200.zip
21474836480 ./media/photos/2019/raw-copy/DSC_0001-1200.zip
10737418240 ./downloads/debian-12-dvd.iso
4294967296 ./downloads/old-vm.qcow2
1073741824 ./docker/volumes/nextcloud.db
The page reads 11 lines as find -printf lines (bytes, path): 11 files totalling 1.48 TB. The space to free is 300 GB.
By kind (SizeKit.kinds):
| Kind | Size | Files | Share |
|---|---|---|---|
| archive | 1.42 TB | 7 | 96% |
| video | 50 GB | 1 | 3% |
| disk image | 14 GB | 2 | 1% |
| app or code | 1 GB | 1 | 0% |
The largest folders below the top, rolled up (SizeKit.folders, as the input sends them):
| Folder | Size | Files |
|---|---|---|
| backups | 1.19 TB | 4 |
| media | 290 GB | 4 |
| media/photos | 240 GB | 3 |
| media/movies | 50 GB | 1 |
| media/photos/2019 | 40 GB | 2 |
| media/photos/2019/raw | 20 GB | 1 |
| media/photos/2019/raw-copy | 20 GB | 1 |
| downloads | 14 GB | 2 |
| docker | 1 GB | 1 |
| docker/volumes | 1 GB | 1 |
The candidates (SizeKit.items; for a duplicate group the size is its extra copies), each as its line in the input (SizeKit.itemLine):
| Id | Type | Size | Line |
|---|---|---|---|
i1 | likely duplicates | 20 GB | i1. copies: 2 files named DSC_0001-1200.zip of 20 GB each, in media/photos/2019/raw, media/photos/2019/raw-copy | extra copies 20 GB |
i2 | single file | 384 GB | i2. file: backups/laptop-2022-01-full.tar.gz | 384 GB | archive |
i3 | single file | 378 GB | i3. file: backups/laptop-2024-01-full.tar.gz | 378 GB | archive |
i4 | single file | 371 GB | i4. file: backups/laptop-2023-01-full.tar.gz | 371 GB | archive |
i5 | single file | 200 GB | i5. file: media/photos/library.photoslibrary.tar | 200 GB | archive |
i6 | single file | 81 GB | i6. file: backups/phone-2024-08.tar.gz | 81 GB | archive |
i7 | single file | 50 GB | i7. file: media/movies/home videos 1998-2005.mkv | 50 GB | video |
i8 | single file | 10 GB | i8. file: downloads/debian-12-dvd.iso | 10 GB | disk image |
Input:
{
"task": "space",
"goal": "Home server for family photos and backups; the 2 TB data disk is 90% full",
"target": "300 GB",
"totals": "11 files, 1.48 TB; by kind: archive 1.42 TB (96%), video 50 GB (3%), disk image 14 GB (1%), app or code 1 GB (0%)",
"folders": "backups - 1.19 TB, 4 files\nmedia - 290 GB, 4 files\nmedia/photos - 240 GB, 3 files\nmedia/movies - 50 GB, 1 file\nmedia/photos/2019 - 40 GB, 2 files\nmedia/photos/2019/raw - 20 GB, 1 file\nmedia/photos/2019/raw-copy - 20 GB, 1 file\ndownloads - 14 GB, 2 files\ndocker - 1 GB, 1 file\ndocker/volumes - 1 GB, 1 file",
"items": "i1. copies: 2 files named DSC_0001-1200.zip of 20 GB each, in media/photos/2019/raw, media/photos/2019/raw-copy | extra copies 20 GB\ni2. file: backups/laptop-2022-01-full.tar.gz | 384 GB | archive\ni3. file: backups/laptop-2024-01-full.tar.gz | 378 GB | archive\ni4. file: backups/laptop-2023-01-full.tar.gz | 371 GB | archive\ni5. file: media/photos/library.photoslibrary.tar | 200 GB | archive\ni6. file: backups/phone-2024-08.tar.gz | 81 GB | archive\ni7. file: media/movies/home videos 1998-2005.mkv | 50 GB | video\ni8. file: downloads/debian-12-dvd.iso | 10 GB | disk image"
}
Reply:
{
"task": "space",
"verdict": "reaches",
"plan": [
{
"item": "i1",
"action": "remove_copies",
"why": "DSC_0001-1200.zip is duplicated in raw and raw-copy at 20 GB each, so the extra copy can be removed.",
"question": ""
},
{
"item": "i2",
"action": "archive",
"why": "laptop-2022-01-full.tar.gz is a 384 GB backup, so it can move off the drive.",
"question": ""
},
{
"item": "i3",
"action": "archive",
"why": "laptop-2024-01-full.tar.gz is a 378 GB backup, so it can move off the drive.",
"question": ""
},
{
"item": "i4",
"action": "archive",
"why": "laptop-2023-01-full.tar.gz is a 371 GB backup, so it can move off the drive.",
"question": ""
},
{
"item": "i5",
"action": "ask",
"why": "library.photoslibrary.tar is 200 GB under media/photos, and its current use isn't clear.",
"question": "Is library.photoslibrary.tar still your live photo library, or can it move to other storage?"
},
{
"item": "i6",
"action": "archive",
"why": "phone-2024-08.tar.gz is an 81 GB backup, so it can move off the drive.",
"question": ""
},
{
"item": "i7",
"action": "archive",
"why": "Home videos 1998-2005.mkv covers years long past, so it can move off the drive at 50 GB.",
"question": ""
},
{
"item": "i8",
"action": "archive",
"why": "debian-12-dvd.iso is a 10 GB installer image, so it can move off the drive.",
"question": ""
}
],
"frees": "1294 GB",
"note": "The plan frees 1294 GB, well past the 300 GB wanted to clear space on the family server."
}
The plan the page trusts (ReplyKit.trusted), candidate by candidate:
| Candidate | Size | Action | Frees | Why / question |
|---|---|---|---|---|
the copies of DSC_0001-1200.zip (i1) | 20 GB | remove_copies (remove extra copies) | 20 GB | DSC_0001-1200.zip is duplicated in raw and raw-copy at 20 GB each, so the extra copy can be removed. |
backups/laptop-2022-01-full.tar.gz (i2) | 384 GB | archive | 384 GB | laptop-2022-01-full.tar.gz is a 384 GB backup, so it can move off the drive. |
backups/laptop-2024-01-full.tar.gz (i3) | 378 GB | archive | 378 GB | laptop-2024-01-full.tar.gz is a 378 GB backup, so it can move off the drive. |
backups/laptop-2023-01-full.tar.gz (i4) | 371 GB | archive | 371 GB | laptop-2023-01-full.tar.gz is a 371 GB backup, so it can move off the drive. |
media/photos/library.photoslibrary.tar (i5) | 200 GB | ask (ask first) | - | library.photoslibrary.tar is 200 GB under media/photos, and its current use isn't clear. Ask: "Is library.photoslibrary.tar still your live photo library, or can it move to other storage?" |
backups/phone-2024-08.tar.gz (i6) | 81 GB | archive | 81 GB | phone-2024-08.tar.gz is an 81 GB backup, so it can move off the drive. |
media/movies/home videos 1998-2005.mkv (i7) | 50 GB | archive | 50 GB | Home videos 1998-2005.mkv covers years long past, so it can move off the drive at 50 GB. |
downloads/debian-12-dvd.iso (i8) | 10 GB | archive | 10 GB | debian-12-dvd.iso is a 10 GB installer image, so it can move off the drive. |
The page's own sum (t.freed): archived files 1.24 TB, extra copies removed 20 GB, in all 1.26 TB against a target of 300 GB. The reply says it frees 1294 GB, read as 1.26 TB.
The verdict: reaches - "The plan frees enough"; it agrees with the page's sum, which reaches the target.
The note: "The plan frees 1294 GB, well past the 300 GB wanted to clear space on the family server."
The page's check (ReplyKit.check): kept.
No notes.
The CSV for the first sample
What SizeKit.toCSV(form, t.acts, built.items) writes for "A full laptop (du -ah)" above: one row per file, biggest first, with the candidate it belongs to, the trusted action and, in a duplicate group, which row is the kept copy and which the extra copies (lines end in CRLF). A file that is not a candidate has no plan.
path,bytes,size,kind,item,plan,copy
Virtual Machines/Windows 11.vmdk,66571993088,62 GB,disk image,i2,ask,
Movies/2023 Wedding/wedding_raw_footage_cam_a.mov,51539607552,48 GB,video,i3,archive,
Movies/2023 Wedding/wedding_raw_footage_cam_b.mov,33285996544,31 GB,video,i4,archive,
Documents/Lightroom Catalog Previews.lrdata,12884901888,12 GB,other,i5,ask,
Downloads/Xcode_15.2.xip,7623566950,7.1 GB,archive,i6,archive,
Movies/2023 Wedding/wedding_final_edit.mp4,6657199309,6.2 GB,video,i7,keep,
Music/Library,5153960755,4.8 GB,other,i8,keep,
Downloads/ubuntu-22.04.4-desktop-amd64.iso,5046586573,4.7 GB,disk image,i9,archive,
Downloads/family photos export.zip,2469606195,2.3 GB,archive,i1,remove_copies,kept copy
Downloads/family photos export (1).zip,2469606195,2.3 GB,archive,i1,remove_copies,extra copy
Movies/screen recording 2024-05-02.mov,1181116006,1.1 GB,video,i10,ask,
Pictures/2024 Lisbon,566231040,540 MB,other,,,
Downloads/zoom_installer.pkg,327155712,312 MB,app or code,,,
Pictures/Scans/passport.pdf,3565158,3.4 MB,pdf,,,
Documents/thesis_final.pdf,2202010,2.1 MB,pdf,,,
Build the input yourself
Load the engine in node (global.window = global; require("./sizekit.js"); require("./replykit.js")), then:
const K = SizeKit, R = ReplyKit;
const form = K.form({ goal: "My laptop is nearly full", target: "3 GB",
listing: "4.2G\t./Videos/holiday_raw.mov\n1.1G\t./Downloads/setup.dmg\n1.1G\t./Downloads/setup (1).dmg\n300M\t./Documents/taxes.zip" });
// form.files: every file as read [{path, dir, name, ext, kind, size}]; form.total and form.target in bytes; form.format
K.kinds(form); K.folders(form); K.duplicates(form) // the totals and the likely duplicates, no run needed
const built = K.buildInput(form); // built.ok is false, with built.note, until two files are read
// built.items: the candidates [{id, type: copies or file, size, files}]; built.warn: what the page warns about
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// const t = R.trusted(reply, built) // {acts: {i1: action}, why, ask, dropped, missing, freed}
// R.check(reply, built) // {status: kept or flagged, notes}
// K.toCSV(form, t.acts, built.items) // every file with its size, kind and plan
Form keys: listing (the pasted listing, any shape above), goal (what the storage is and what the person is trying to do, or empty) and target (the space to free, such as 60 GB or 500M; empty means a fifth of the total). K.freed(t.acts, built.items) gives the same sum for a plan you edit yourself.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://space-hog.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://space-hog.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// space-hog.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://space-hog.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://space-hog.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// SpaceHog.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class SpaceHog {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://space-hog.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://space-hog.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://space-hog.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://space-hog.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"space-hog"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "space-hog"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "space-hog" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "space-hog"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "space-hog"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "space-hog" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "space-hog"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "space-hog" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "space", "goal": "My laptop is nearly full", "target": "3 GB", "totals": "4 files, 6.69 GB; by kind: video 4.2 GB (63%), disk image 2.2 GB (33%), archive 300 MB (4%)", "folders": "Videos - 4.2 GB, 1 file\nDownloads - 2.2 GB, 2 files\nDocuments - 300 MB, 1 file", "items": "i1. copies: 2 files named setup.dmg of 1.1 GB each, both in Downloads | extra copies 1.1 GB\ni2. file: Videos/holiday_raw.mov | 4.2 GB | video\ni3. file: Documents/taxes.zip | 300 MB | archive"}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "space", "goal": "My laptop is nearly full", "target": "3 GB", "totals": "4 files, 6.69 GB; by kind: video 4.2 GB (63%), disk image 2.2 GB (33%), archive 300 MB (4%)", "folders": "Videos - 4.2 GB, 1 file\nDownloads - 2.2 GB, 2 files\nDocuments - 300 MB, 1 file", "items": "i1. copies: 2 files named setup.dmg of 1.1 GB each, both in Downloads | extra copies 1.1 GB\ni2. file: Videos/holiday_raw.mov | 4.2 GB | video\ni3. file: Documents/taxes.zip | 300 MB | archive"}
# - see the field table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "space", goal, target, totals, folders, items} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses space-hog:<task>:<inputHash>:a<attempt> (the one task is space), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="space-hog:space:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '.verdict, .frees, (.plan[] | "\(.item) \(.action)")' reply.json
import hashlib, time
key = "space-hog:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
print(reply.get("frees", ""))
for p in reply.get("plan") or []:
print(p["item"], p["action"])
import { createHash } from "node:crypto";
const key = `space-hog:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
console.log(reply.frees);
for (const p of reply.plan || []) console.log(p.item, p.action);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "space-hog:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
plan, _ := reply["plan"].([]any)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"])
fmt.Println(reply["frees"])
for _, x := range plan {
p := x.(map[string]any)
fmt.Println(p["item"], p["action"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "space-hog:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.get("verdict"));
System.out.println(reply.path("frees").asText());
for (JsonNode p : reply.path("plan")) System.out.println(p.path("item").asText() + " " + p.path("action").asText());
require "digest"
key = "space-hog:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
puts reply["frees"]
(reply["plan"] || []).each { |p| puts "#{p["item"]} #{p["action"]}" }
$key = "space-hog:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", ($reply["verdict"] ?? ""), "\n";
echo $reply["frees"] ?? "", "\n";
foreach ($reply["plan"] ?? [] as $p) echo $p["item"], " ", $p["action"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"space-hog:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply!["verdict"]}");
Console.WriteLine($"{reply["frees"]}");
foreach (var p in reply["plan"] as JsonArray ?? new JsonArray()) Console.WriteLine($"{p!["item"]} {p["action"]}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: space-hog:space:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "space", "goal": "My laptop is nearly full", "target": "3 GB", "totals": "4 files, 6.69 GB; by kind: video 4.2 GB (63%), disk image 2.2 GB (33%), archive 300 MB (4%)", "folders": "Videos - 4.2 GB, 1 file\nDownloads - 2.2 GB, 2 files\nDocuments - 300 MB, 1 file", "items": "i1. copies: 2 files named setup.dmg of 1.1 GB each, both in Downloads | extra copies 1.1 GB\ni2. file: Videos/holiday_raw.mov | 4.2 GB | video\ni3. file: Documents/taxes.zip | 300 MB | archive"}
# - see the field table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "space-hog:space:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `space-hog:space:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "space-hog:space:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "space-hog:space:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "space-hog:space:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: space-hog:space:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"space-hog:space:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);