Skip to content
vlnnPublic

About

file-based logging from Alfred

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

wisp

One-line logs from Alfred. Type l, see what you logged, newest first. Type l and some words: the first row logs them, the rows below are the records they match.

l making API v1          ↩   logged
l testing api manually   ↩   logged
l api                        Log "api"
                             testing api manually      14:31 today
                             making API v1             14:02 today
l                            everything, newest first

Every record is a markdown file in a folder you choose, holding the line and nothing else. When it was logged is the file's creation time, so editing a file never moves it in the list and the folder needs no index, no database and no sync of its own.

Key On a record
↩ a menu of what this record can do, the usual thing on the first row: Open for a log, Finish for a task, Decide for a question, Reopen for a done or decided one, then Open, Rename, Copy, Trash. ↩ again runs it; type to narrow. On a question what you type is the answer: the first row reads Decide "question | answer" and ↩ settles it, while the other rows stay put
⇧↩ copy the text; on a task, finish it; on a question, open the answer field (and on a settled one, reopen it)
⌥↩ move the file to the Trash (Finder's, with Put Back)
⌘↩ rename: the text is prefilled, edit it, ↩
⌘C copy the text
⌘L large type
⇧ or ⌘Y Quick Look

Matching: every word you type must be a prefix of a word in the line; case and diacritics are ignored. One word may be a rule instead, spelled with @: a day (@today, @yesterday, a weekday @mon or @friday, the most recent one, @2026-10-03, a month @2026-09) or a range around today (@week, Monday to Sunday, @month, @year). The list narrows to records from then. Without the @ a day is an ordinary word, so l today finds "today standup", and any @word that is not a rule, like @Flash, is ordinary text too. The first row offers what belongs to the record: l wrote article #articles @week -draft logs "wrote article #articles" and lists this week's #articles records that are not drafts, while l @today or l #articles alone only search. A word with a dash in front leaves out the lines it matches instead: l api -v2 lists the api records that say nothing about v2, l . -build the open tasks that are not about the build. --force, x-ray and a negative number like -5 stay ordinary words. A word with # in front keeps the records tagged with it (see Tags below): l #art lists everything tagged #articles or #art-history, l . #work @today today's open work tasks, and -#draft leaves the drafts out. Without the #, articles matches the word anywhere in the line, tags included. There is no cap on the number of rows.

Tags

A word with # and a letter in front, anywhere in the line, is a tag: wrote small article on modules #articles. A record can carry several, and any kind can: a task, a question, a checklist template. Tags are written in lowercase, they never reach the file name, and C#, fixed #123 or a link's /#section stay plain text. Nothing has to be declared: a tag exists once a record carries it.

Once it exists, l offers it. The first row keeps what you typed with no tag added; up to three rows below it keep the line with a tag added, so ↩ ignores them and ⌘2, ⌘3, ⌘4 (or ↓ ↩) take one:

l wrote small article on modules   Log "wrote small article on modules"
                                   Log "… on modules #articles"   with #articles · the line names it
l essay draft                      Log "essay draft"
                                   Log "essay draft #articles"    with #articles · seen with essay
l wrote #art                       Log "wrote #articles"          with #articles · completes what you typed
l met @fl                          Log "met @Flash"               with @Flash · completes what you typed

A line names a tag when one of its words and the tag share a stem of four letters or more (article, #articles). A line is seen with a tag when one of its words came with that tag in two records or more, and in more than half the records it appears in at all. While a # or @ word is being typed, the rows complete it instead: tags most used first, mentions in the spelling you use most; when nothing completes it, the line's own suggestions follow as usual. A record's tags are repeated at the end of its subtitle, 14:02 today · #articles.

l # lists checklists, as before; l #a, a # word and nothing else, lists the tags it starts, most used first, above the records carrying them:

l #art                             #articles      12 records · last Tue 13 Oct 14:02
                                   #arts          1 record · last yesterday 09:30
                                   essay on modules #articles     14:02 Tue 13 Oct

↩ or ⇥ on a tag finishes it in the query (l #articles ), so the list narrows to it.

Tasks, notes, questions, decisions

A line is a plain log unless it starts with a sigil:

l . fix the build            Task "fix the build"      ↩ keeps `. fix the build`
l ! keys are in 1password    Note "keys are in 1password"
l ? postgres or sqlite       Question "postgres or sqlite"
l = postgres | two writers   Decision "postgres | two writers"
l .                          open tasks, newest first
l . build                    open tasks about build
l *                          done tasks: "done 16:40 today · 09:12 today"
l ?                          open questions
l =                          decisions: "decided Tue 7 Oct 11:02 · Mon 6 Oct 16:30"
l !                          notes

⇧↩ (or ↩ ↩, Finish) on a task turns . fix the build into * fix the build in place; the file keeps its creation time and its name, and the time it was marked is the file's modification time, so a done task knows both when it was written and when it was finished. ⇧↩ again reopens it. A question is settled with its resolution: ↩ on it, type the answer, ↩ again, and ? why is the sky green turns into = why is the sky green | it is blue in place, so the question and what came of it stay on one line (⇧↩ opens the same answer field). Reopen makes it a question again without losing the answer, and a second Decide appends another | …. A decision can also be logged directly with =. . and * need a space after them, so .gitignore moved and *args again stay plain logs; ?, ! and = never start a word, so ?will it work is the same question as ? will it work. Words without a sigil match every kind, so l build finds the task too.

The sigils are deliberately the bullet journal's: . a task, * crossed out, ! worth remembering, with ? and = for the thing a work log is worst at answering months later, why. Each kind has its own dot in the list: green for a log, terracotta for a note, and for the kinds that get settled a ring while they are open and a filled dot once they are: ochre for a task, blue for a question. Under ask, the Ask row wears the open question's ring and kept answers the decision's dot. The colours are the top of workflow/icons.py; uv run python workflow/icons.py redraws them.

Checklists: l # name

A checklist is a list of steps you go through more than once: a release, a morning, packing a bag. # with a space starts a template; #api is a tag.

l #                          checklists, with how many steps and whether a run is open
l # release checklist        Start "release checklist"   ↩ starts a run, or continues the open one
                             # release checklist     7 steps · run open since 09:12 today
l # packing                  Checklist "packing"         ↩ creates it, then lists its (empty) steps
l . release                  the open run, as a task: 4/7 · started 09:12 today
l * release                  finished runs: done 17:40 today · 7/7 · 09:12 today

The template is a file whose body is box lines (- [ ] bump version), one per step; ↩ on it then Steps lists them, type a step and ↩ adds it. Starting copies the body into a new task (. release checklist) with every box cleared, so a run lives the life of any task: open under l ., finished under l *, Finish and Reopen in its menu, and the folder is the history. In the steps, ↩ ticks a box (and unticks it), typing narrows the list or offers to add what matches nothing, ticked steps sink to the bottom so ↩ is on the next one, and once every box is ticked the first row is Finish. The steps of a template lead with Start, those of a finished run with Run again; either makes a fresh run with every box cleared and opens its steps. The first row under l # name is for when you have typed the whole name: Start when nothing is open, Continue (with the progress) when a run is, and ⌘↩ starts a fresh run anyway; a prefix match is listed below it as usual, and ↩ on that row opens its menu. A checklist's icon is a plum ring, since a template is never settled.

Install

  1. Build dist/wisp.alfredworkflow with make build (or take Wisp.alfredworkflow from Releases) and double-click it.
  2. In the workflow's configuration set Log folder. The default is ~/log; it is created on the first log.
  3. Optional: hotkeys. The workflow ships two Hotkey objects, one wired to the l list and meant for HYPER+L (⌃⌥⇧⌘L), one wired to ask and meant for HYPER+/ (⌃⌥⇧⌘/). Alfred clears hotkey combinations on import so they never clash with yours, so double-click each object once and press the combination you want.

Nothing to install: the bundle runs on macOS's own /usr/bin/python3 (3.9+) with the standard library only. It carries hoard's toolkit next to wisp, the part of that library any Alfred workflow needs: rows, folding, the model client, the macOS calls.

Ask the log: ask

Pick a model in the workflow configuration, one of:

  • Model server: the URL of llama.cpp's llama-server, or anything else that answers OpenAI-style /v1/chat/completions, e.g. http://127.0.0.1:8080;
  • Apple Intelligence: a checkbox for Apple's on-device model (macOS 26 or later, an Apple-silicon Mac with Apple Intelligence turned on, and the Command Line Tools for a one-time compile of a small Swift helper into the workflow's data folder). While it is ticked the server fields are ignored.

Until one of them is set, ask only tells you to set it.

ask today summary            Ask "today summary"   ↩ asks the model about 1 240 lines
ask what did I do with api   Ask "what did I do with api"   ↩ asks the model about 23 lines matching api
                             testing api manually      14:31 today
                             making API v1             14:02 today
ask                          the whole log, newest first

Under the Ask row the list is exactly what l would show for the same words, same dots and same keys, so you can see what the model has to go on before you ask.

↩ on Ask opens a text view inside Alfred and the answer streams into it as the model writes. In the view, ↩ copies the answer, ⌥↩ keeps it as a file and opens it, and ⌘↩ asks a follow-up: type the next question, ↩, and the earlier questions and answers stay on screen with the new answer streaming in under them; the model gets the first question with its log, every answer so far and the new question, so "which of those are done?" works, and ↩ or ⌥↩ then copy or keep the whole conversation. Esc closes the view and the answer is gone unless you kept it; a thread lasts as long as Alfred's cache for the workflow does. Kept answers are markdown files (question on the first line, answer below) in Alfred's data folder for the workflow, opened as they are kept; they are not listed in Alfred.

The model sees each line as YYYY-MM-DD HH:MM text; the other kinds carry a word before the text, TODO, QUESTION, NOTE, or DONE and DECIDED with the time they were settled, so "what is still open?", "what did I finish this week?" and "why postgres?" are questions it can answer. A day in the question, with or without the @ (what did I do yesterday, summary of 2026-09), or a range with it (@week), sends those lines and nothing else; a bare week stays prose, since "last week" is not this one, and a sigil (ask . what is still open) sends that kind only. A dashed word (ask what did I do today -meetings) keeps the lines it matches away from the model altogether, and the log's heading says so. The question goes with the log, newest line first, cut at about 12 000 characters for a server and 6 000 for the Apple model, whose context window is 4 096 tokens. If any line matches the question's words (the rows listed under Ask), only those lines go, however old they are, so "what did I do with api" still works once the log has outgrown the budget; a question no line matches whole, like "logs about wisp", sends the lines matching any of its words (short and filler words aside), and the log is headed with what it was matched on. Only when nothing matches at all does the newest log go, so "today summary" sends today. The Ask row says how many lines go and what they match. The model only ever runs when you press ↩ on Ask, never while you type. On a server, pick an instruct model rather than a thinking one — the workflow asks the template not to think, which Qwen3-style models honour, and hides a <think> block if one comes back anyway. Model names the alias when the server serves several; Model server API key is sent as a bearer token.

wisp ask --dry-run "today summary" prints exactly what the model would be sent; wisp ask --keep "today summary" saves the answer the way ⌥↩ does; wisp ask --thread <job folder> "and then?" follows up on a job from the view (the folder is in wisp check's jobs folder).

How files are named

reading docs becomes reading-docs.md; a second reading docs becomes reading-docs-2.md; the sigil never reaches the name, so . fix the build is fix-the-build.md, and neither does a tag, so wrote article #articles is wrote-article.md and tagging a record never moves it. The name is only a convenience for Finder and grep. Rename changes the text in place and then moves the file to a name that fits, so the file keeps its creation time.

From a terminal

export log_folder=~/log
uv run wisp keep "reading docs"
uv run wisp filter api | jq '.items[].title'
uv run wisp rename ~/log/reading-docs.md "reading Apache docs"
uv run wisp trash ~/log/reading-docs.md
uv run wisp keep ". fix the build"
uv run wisp mark ~/log/fix-the-build.md      → * fix the build (and back again)
uv run wisp decide ~/log/why-the-sky.md "it is blue"   → = why the sky … | it is blue
uv run wisp menu ~/log/fix-the-build.md      the ↩ menu, as Alfred's JSON
oracle_url=http://127.0.0.1:8080 uv run wisp ask "today summary"

oracle_url, oracle_model and oracle_key configure the model the way the panel does; alfred_workflow_data is where kept answers go (default ~/Library/Application Support/wisp).

wisp check prints the resolved settings: the log folder and how many records it holds, the data and jobs folders, and whether the model server answers (or what the Apple helper still needs).

filter, ask-filter, ask-more, rename-filter, menu and view print Alfred's JSON, mark and decide print the new line, ask prints the answer (ask --keep and keep-answer "<question>" "<answer>" save it and print the file's path), the rest print the text they acted on. wisp act <action> <arg> is what Alfred's ↩ runs: keep, mark, open, copy or trash, each also a subcommand of its own. wisp ask --job <folder> "…" is what the text view runs in the background: progress goes to that folder instead of notifications.

Development

make test                                pytest
make lint                                ruff check and format, mypy
make build                               → dist/wisp.alfredworkflow, by hoard.build
make check                               build, import the package from the bundle, run scripts/smoke.py
uv run --isolated --python 3.9 pytest    the interpreter the workflow runs on

workflow/plist.toml tells hoard.build where the canvas (workflow/info.plist), the package (src/wisp) and the Text View script are; the build stamps the name, bundle id and the version from pyproject.toml into the plist and vendors wisp and hoard side by side. hoard is pinned to a tag in pyproject.toml; to move to a newer one, change the tag and run uv lock. CI runs the tests and lint on Python 3.9 and 3.13, builds the bundle on macOS and publishes it: every push to main updates the latest pre-release, a v* tag makes a release with notes. To cut one:

git tag v0.2.0 && git push origin v0.2.0

NOTES.md lists what is deliberately not here yet and where it would go.

About

file-based logging from Alfred

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages