Strea.md is a personal knowledge management and time-tracking CLI tool
  • Rust 68%
  • Kotlin 31.1%
  • Nix 0.9%
Find a file
Renovate Bot d88ff88b6a
All checks were successful
Continuous Integration / Conventional Commits (push) Successful in 23s
Continuous Integration / Lint, Check & Test (push) Successful in 14m27s
Continuous Integration / Build Package (push) Successful in 25m12s
chore(deps): update rust crate grafeo to v0.6.1
2026-11-02 02:20:27 +01:00
.forgejo/workflows ci: run the checks on pushed branches only, not on tags 2026-10-18 16:31:31 +02:00
.github/workflows ci: run the checks on pushed branches only, not on tags 2026-10-18 16:31:31 +02:00
.vscode chore: improve dev setup 2025-06-22 12:52:15 +02:00
android fix(android): show a note by its new name after the editor renamed it 2026-10-29 21:35:43 +01:00
crates feat(ffi): bind moveToNow, the clock of an editor 2026-10-26 22:48:52 +01:00
zed-extension chore(deps): lock file maintenance 2026-10-26 21:41:37 +01:00
.cz.toml ci: add bump ci 2026-09-27 21:45:15 +02:00
.envrc refactor: rewrite in rust 2026-03-29 18:28:03 +02:00
.gitignore chore: ignore the keystores of the Android app 2026-10-12 08:47:36 +02:00
AGENTS.md docs: the editor keeps what was typed and hands on a renamed file 2026-10-29 21:35:47 +01:00
Cargo.lock chore(deps): update rust crate grafeo to v0.6.1 2026-11-02 02:20:27 +01:00
Cargo.toml bump: version 0.43.0 → 0.44.0 2026-10-26 22:07:43 +00:00
CHANGELOG.md bump: version 0.43.0 → 0.44.0 2026-10-26 22:07:43 +00:00
flake.lock chore(deps): lock file maintenance 2026-10-26 21:41:37 +01:00
flake.nix fix(nix): declare streamd as the main program 2026-10-20 12:43:01 +02:00
LICENSE.md docs: license under AGPL 2025-10-12 11:19:03 +02:00
osv-scanner.toml fix(android): make SSH keys ECDSA and drop the unmaintained eddsa 2026-10-18 15:23:40 +02:00
README.md docs: the editor keeps what was typed and hands on a renamed file 2026-10-29 21:35:47 +01:00
renovate.json ci: allow major upgrades from renovate 2026-01-18 17:42:16 +01:00
REQUIREMENTS.md docs: the editor keeps what was typed and hands on a renamed file 2026-10-29 21:35:47 +01:00
streamd.svg feat: add streamd logo 2026-02-15 17:40:17 +01:00

strea.md

The Strea.md-Logo: A tag on an endless paper roll

Strea.md is a personal knowledge management and time-tracking CLI tool. It organizes time-ordered markdown files using @tag annotations, letting you manage tasks, track time, and query your notes from the terminal.

Installation

Debian/Ubuntu (.deb package)

Download and install the latest release:

wget https://git.konstantinfickel.de/kfickel/streamd/releases/download/vX.Y.Z/streamd_X.Y.Z_amd64.deb
sudo dpkg -i streamd_X.Y.Z_amd64.deb

This includes shell completions for bash, zsh, and fish. They cover the built-in commands only; for the commands your .streamd.toml configures, see Shell Completions.

Static Binary

Download the statically-linked binary:

wget https://git.konstantinfickel.de/kfickel/streamd/releases/download/vX.Y.Z/streamd-X.Y.Z-linux-x86_64
chmod +x streamd-X.Y.Z-linux-x86_64
sudo mv streamd-X.Y.Z-linux-x86_64 /usr/local/bin/streamd

Nix

Using the flake directly:

nix run git+https://git.konstantinfickel.de/kfickel/streamd

Or add to your NixOS/Home Manager configuration using the provided homeManagerModules.default.

The default Nix package declares meta.mainProgram = "streamd", so lib.getExe resolves its CLI executable.

Core Concepts

  • Shards — Sections of markdown files, organized hierarchically by headings. Each shard can contain markers, tags, and nested child shards.
  • Markers — @tags like @Task, @Done, @Waiting, or @Timesheet that give shards semantic meaning and place them into dimensions. Which tags do what is set in the project configuration.
  • Dimensions — Classification axes (e.g. task state, project, timesheet) that categorize shards. Some dimensions propagate to child shards.

File Format

Markdown files are named with a timestamp: YYYYMMDD-HHMMSS [markers].md

For example: 20260131-210000 Task Streamd.md

An optional _file_type suffix can follow the timestamp to say what kind of file it is:

YYYYMMDD-HHMMSS_<file_type> [markers].md

For example: 20260413-083000_daily.md. What a suffix means is up to the [file_types] table of your configuration: [file_types.daily] with dimension = "file_type" places every _daily file, with all its shards, as file_type = "daily". A suffix that is not configured places nothing.

Within files, @-prefixed markers at the beginning of paragraphs or headings define how a shard is categorized.

Commands

  • streamd — Run the default_command of your .streamd.toml (the daily note, after streamd init); without one, print the help
  • streamd init — Write a starter .streamd.toml with the dimensions, tags and file types for notes, tasks and timesheets, and the commands new, daily, edit, task and timesheet (see Commands of your own)
  • streamd config edit — Open the .streamd.toml of your stream in the editor; streamd config edit --global opens the application configuration (~/.config/streamd/config.toml) instead. Neither is checked first, so this also opens a file that no longer loads
  • streamd config tags list — List every tag and marker used anywhere in the stream (subfolders included), alphabetically
  • streamd config tags maintenance — List the tags placed in no dimension with how often each is written; with -i, go through them: remove their @, add them to a dimension or merge them into another tag
  • streamd config tags add <Name> --dimension <key> [--description "…"] — Place a tag in an extensible dimension of .streamd.toml (e.g. streamd config tags add @ChuckNorris --dimension person)
  • streamd lsp — Start the LSP server (stdin/stdout transport; see Editor Integration below)
  • streamd cache — Show the local cache's location, size and contents (see Local Cache below)
  • streamd cache rebuild — Discard the cache and reindex the corpus from scratch
  • streamd cache clear — Delete the cache
  • streamd completions <shell> — Print shell completions, including the configured commands
  • streamd -i (or --interactive) — Open the interactive front-end of the configured commands

The commands your .streamd.toml configures come on top. With the one streamd init writes:

  • streamd new — Create a new timestamped markdown file and open it in your editor
  • streamd daily [YYYYMMDD] — Open today's daily note (or create it if missing), printing the reminders that are due first; pass a date (or a time of one, YYYYMMDD-HHMM) to open the note of that day instead (-i to browse the notes interactively)
  • streamd edit [which] — Edit a file by position (1 = most recent, -1 = oldest), or the last one before a date (2026-10-10) or before a period ago (P1W); an alias of streamd new edit
  • streamd daily edit [which] — The same among the daily notes: streamd daily edit 2 opens the note before the latest, streamd daily edit P1W the one of a week ago
  • streamd daily append @Task call Bob — Add a heading to today's daily note without opening it, with the time it was written (# @Task @143000 call Bob); without a note for today, one is created holding that line alone
  • streamd task — Show all open and waiting tasks, numbered for easy reference (--due 7d includes the tasks of the next seven days, --due any or --show-future those of any date, --project=Hausbau only lists the tasks of one project)
  • streamd task edit N — Edit task N in your editor, jumping to the task's line
  • streamd task done N... — Mark tasks as done by editing their tags (@Task → @Task @Done)
  • streamd timesheet — Generate time reports from @Timesheet markers (--decimal for decimal hours, --debug for every timecard by day, -i to browse it interactively)

Every configured command takes -i to open on it in the interactive front-end.

Without a .streamd.toml, or without [commands] in it, there are no such commands — not even new, daily and edit, which were built in up to 0.23 (see Upgrading below).

Configuration

User Configuration

Streamd reads its user configuration from ~/.config/streamd/config.toml (XDG standard). The main setting is base_folder, which points to the directory containing your stream files (defaults to the current working directory).

Set STREAMD_BASE_FOLDER to use a different stream folder for one invocation. A non-empty value takes precedence over config.toml, which is then not read at all:

STREAMD_BASE_FOLDER=~/work-notes streamd task

Project Configuration

A .streamd.toml file in your stream files directory configures the stream: its timezone, the dimensions shards are placed in, the tags and file types that place them, and the commands that work on them. streamd init writes a starter file with your system timezone.

timezone = "Europe/Berlin"  # Optional: timezone for day boundaries and file timestamps; must be a name the timezone database knows
warn_unplaced_tags = true     # Optional; default false. true: point out tags placed in no dimension
default_command = "daily"   # Optional: the configured command `streamd` runs without one; default: print the help

[dimensions.person]
name = "Person"        # Optional; defaults to the key split at "_" and capitalised ("shopping_list" -> "Shopping List")
description = "People I work with"
propagate = true       # Optional; the default. Child shards inherit the value
extensible = true      # Optional; default false. New tags can be added to it from the editor or `streamd config tags add`

[dimensions.meeting]

[dimensions.shopping_list]
propagate = false

[tags.ChuckNorris]
dimension = "person"               # @ChuckNorris places "ChuckNorris" in the person dimension

[tags.Standup]
description = "The daily standup"  # Shown in editor completions
dimension = "meeting"              # Same as: [[tags.Standup.rules]] then = { meeting = "Standup" }

[tags.Buy]
description = "Something to buy"

# All matching rules of a tag apply, top to bottom; a later one overwrites an earlier one.
[[tags.Buy.rules]]
then = { shopping_list = "open" }

[[tags.Buy.rules]]
with = ["Bought", "Got"]           # Any of these tags on the same line; "Bought" is short for ["Bought"]
then = { shopping_list = "done" }

A tag is a table with an optional description, optional alias (see Aliases below) and exactly one of dimension and rules. Every dimension a tag places in must be defined under [dimensions]. Mistakes are reported all at once, with their location and a suggested fix:

/home/me/notes/.streamd.toml is invalid:
  - tags.Buy.rules[1].then: unknown dimension "shoping_list"
      defined dimensions: person, meeting, shopping_list
      did you mean "shopping_list"? (or add a [dimensions.shoping_list] section)

File types

The _<suffix> of a file name is a file type. [file_types] says what each one places, written exactly like a tag — a table with description, alias and one of dimension and rules:

[dimensions.file_type]
[dimensions.journal]

[file_types.review]
dimension = "file_type"              # ..._review.md is placed as file_type = "review"

[file_types.daily]
description = "The daily note"       # Shown when you hover over a value it places
alias = "journal"                    # ..._journal.md is the same type

[[file_types.daily.rules]]
then = { file_type = "daily", journal = "day" }   # Any dimension, and several of them

[[file_types.daily.rules]]
with = "Short"                       # Together with a tag of the file's first line
then = { journal = "brief" }

A file type places the root shard of its files — the whole file, and through dimensions that propagate, every shard in it — as if the suffix were a tag in front of the first line. So a tag of the first line that places the same dimension wins, as a later tag does. Nothing is placed automatically: a suffix without an entry places nothing, and the editor points it out (with a quick fix that adds it, see Editor Integration). File types and tags are separate names: _daily has nothing to do with a tag @daily.

The starter file. Nothing is merged in behind your back, and streamd never writes into your .streamd.toml on its own: the dimensions, tags, file types and commands a stream has are those in the file. streamd init writes the setup for notes, tasks and timesheets into it once — the file_type, task, project and timesheet dimensions, the file type daily, the tags @Task (with @Done, @Waiting, @Cancelled, …), @WaitingFor and @Timesheet (with @Break, @VacationDay, …) — together with the commands; from then on they are yours to change. Without a .streamd.toml there are no dimensions and no commands at all.

Upgrading from 0.25 or earlier. streamd tags is streamd config tags list and streamd tags add is streamd config tags add. config is now a built-in command, so a [commands.config] of your own needs another key; tags is free for one.

Upgrading from 0.23 or earlier. Four things that used to be built in are configuration now, and an existing .streamd.toml has none of them until you add them:

default_command = "daily"            # `streamd` alone opened the daily note

[file_types.daily]                    # `_daily` files were placed as file_type = "daily" automatically
dimension = "file_type"

[commands.new]                       # `streamd new`
type = "file"

[commands.daily]                     # `streamd daily`
type = "file"
placement = { file_type = "daily" }
recurrence = { rule = "FREQ=DAILY" }
reminders = true

[commands.edit]                      # `streamd edit [n]`
type = "alias"
command = "new edit"

default_command goes above the first [table]. Add a [file_types.<suffix>] table for every other suffix you use (_review, _log, …) and want placed — a reminder that expects file_type = "…" needs it. Your [dimensions.file_type] section stays as it is; streamd no longer rewrites it. streamd new no longer renames the file with its tags when you close the editor: it keeps the name it was created with.

A file command now writes since and start as one table, recurrence = { rule = "FREQ=DAILY", start = 2026-01-01 }, and a reminder its days or since inside trigger = { … }. The trigger if (due on every day with a shard placed like this) is gone: a reminder is due from a moment on, and { if = … } is reported as no trigger. Every reminder is now a command of its own, without [[commands.<key>.rules]] (see Reminders).

Four names changed with it: the table [file_type] is [file_types] (the dimension is still file_type), type = "state_command" is type = "state", the rules of a tag or file type are [[tags.Task.rules]] instead of [[tags.Task.if]], and ignore_unplaced_tags = false is warn_unplaced_tags = true. An old name is reported as an unknown field, next to the names that are valid there.

The shorthand Name = "dimension" in [tags] and [file_types] is gone as well: every tag and file type is a table of its own, so ChuckNorris = "person" becomes [tags.ChuckNorris] with dimension = "person". The error names the table to write.

Upgrading from 0.16 or earlier. Earlier versions merged the task and timesheet setup into every .streamd.toml in memory. Now a file that relies on it fails to load (commands.task.dimension: unknown dimension "task") or leaves @Task unplaced. Copy the [dimensions.task], [dimensions.project], [dimensions.timesheet], [tags.Task], [tags.WaitingFor] and [tags.Timesheet] sections from the file streamd init writes (run it in an empty folder) into yours.

Adding tags. A tag you use that is neither configured nor part of a rule is unplaced. The editor offers "Add @ChuckNorris to Person" for it, once per extensible dimension (the streamd init file makes project and file_type such), and streamd config tags add does the same from the command line. Either appends, keeping everything else as it is:

[tags.ChuckNorris]
dimension = "person"
description = "There are no jokes about him."

From the editor, description is left empty: the change to .streamd.toml opens (in Zed as a multibuffer) so you can type it in before saving. With warn_unplaced_tags = true, unplaced tags are shown as editor hints, and streamd config tags list warns about each on stderr. A tag you only mention in a description of .streamd.toml (description = "Notes on a topic, like @rust") counts too: it is hinted at and can be added right there, and it is listed and warned about with the tags of your notes. To go through all of them at once — add, rename or drop them —, see Tag maintenance.

Combining tags. A rule belongs on the tag that owns the dimension: @Timesheet has a rule with = "Break", so @Timesheet @Break and @Break @Timesheet both give a break. If two different tags on one line set the same dimension, the one written later wins.

Aliases. A tag can go by other names. Give its table an alias — one name or a list:

[tags.X-XYZ]
description = "The team"
dimension = "team"
alias = ["PowerTeam"]        # or alias = "PowerTeam"; default: none

@PowerTeam is then interchangeable with @X-XYZ: the same rules, the same placed value (team = "X-XYZ") and the same description. Finding references of either finds both, the editor's workspace symbol search finds one by the other, and, with partitioned_by = "team", streamd task --team=PowerTeam is the same as --team=X-XYZ. streamd never rewrites the name you wrote: a transition keeps @Aufgabe (an alias of @Task) as it is and only adds or replaces tags by their real names, rename changes exactly the name under the cursor, and streamd config tags list lists names as written. A table with an alias still needs dimension or rules. Tags that only appear in with, like @Done, get alternatives through the with list instead (with = ["Done", "Erledigt"]), and with must name the tag itself, never an alias.

Commands of your own

Every [commands.<key>] table becomes the command streamd <key>. Its type says what it does: a state command lists the shards of one dimension that are in one of its open_if states and moves them to other states; a timesheet reports the working time logged in one dimension; a reminder says when something you expect regularly is missing (see Reminders); a file command creates the files of your stream and finds them again (see File commands); an alias runs another command or a shell script (see Aliases of commands). An activity shows on which days the shards of some queries were written (see Activity). A stream can have several of each — a task and a tell state command, say, or one timesheet per job. For example (streamd init writes much the same, plus transitions to cancel a task and to wait for one, with the periods commented out):

[commands.task]
description = "Todo Management"   # The help text of `streamd task`
type = "state"
dimension = "task"
open_if = ["open", "waiting"]     # The states listed; in any other, a task is done
partitioned_by = "project"        # Optional: one dimension or a list; see below

[[commands.task.transitions]]
name = "Mark as Done"             # Help text, and the title of the editor's quick fix
subcommand = "done"               # Optional: `streamd task done N...`; without it, editor only
from = ["open", "waiting"]
to = "completed"

[commands.timesheet]
description = "Time Tracking"
type = "timesheet"
dimension = "timesheet"
rounding = "minutes_floor"        # Optional: how a timecard becomes whole minutes (see below)
append_to = "daily"               # Optional: the file command `streamd timesheet break` writes into

[commands.timesheet.values]       # What the values placed in the dimension stand for
card = "CARD"                     # Required: starts working time
break = "BREAK"                   # Required: ends it
vacation = "VACATION"             # Optional, as are holiday, sick_leave and undertime

[[commands.timesheet.periods]]    # Optional; add one to get a report
start = 2026-01-01                # A bare TOML date: no quotes, no time
end = 2026-06-30
hours_per_week = 38.0

[[commands.timesheet.periods]]    # Periods must not overlap
start = 2026-07-01                # No end: open, the report runs until today
hours_per_week = 40.0

Periods. The last period may leave out end; it then lasts until you add another one (and give it an end). A period with hours_per_week = 0.0 expects nothing: its weekdays without entries are left out of the report, as a weekend is, instead of showing up as missing, and the days you do work count as worked.

Rounding. Timesheet entries carry seconds (@091540), and each timecard counts as whole minutes. With rounding = "minutes_floor", the default, a started minute does not count: 09:00:40–12:00:20 is 179 minutes. That costs half a minute per timecard on average, which adds up to hours over a year; "minutes_round" rounds each timecard to the nearest minute instead (180 here), and "minutes_ceil" counts every started minute.

Stopping work. With append_to, streamd timesheet break ends the card that runs today: it appends a break to the file that file command has for now — the daily note of today, created if there is none — and prints Break at 17:30 in <file>. Words after break follow the tags: streamd timesheet break back tomorrow writes

# @Timesheet @Break @173000 back tomorrow

The tags are the fewest that place the break value, never an alias. Nothing is written while no card of today is open (`timesheet`: not working, no card of today is open), and the configuration is rejected if no tags can write a break into those files.

Transitions edit tags. A transition does not know which tags to write: streamd works it out from your [tags], trying the smallest changes to the line's tags and checking each with the same localization everything else uses; at the same size, replacing a tag beats adding one, so no outdated tag is left behind. It only touches tags of the command's dimension, and never changes any other dimension. So streamd task done turns @Task Call Bob into @Task @Done Call Bob, @Task @Waiting Call Bob into @Task @Done Call Bob (and cancelling it gives @Task @Cancelled Call Bob), and @WaitingFor Bob sends it into @Task @Done Bob sends it — and on @Task @Tell Anna, where @Done would tell Anna as well, it writes @Task @Completed @Tell Anna.

Partitions. partitioned_by names dimensions ("project", or ["project", "person"]) that split a state command's items. Each becomes a flag of the listing: streamd task --project=Hausbau lists only the tasks whose project is Hausbau — also those that inherit it, like a - @Task under ## @Hausbau. The numbers stay those of the full list, so the listing can have gaps, and streamd task done 5 works without the flag. Every task shows its values after its state: [5] [waiting] (Hausbau) --- file.md:3 ---. In the editor, Find References on @Task (or @WaitingFor) of a task in a project only finds the tasks of that project, and says so: References of @Task @Hausbau. Other tags, and tasks in no project, are found everywhere.

Checked up front. When .streamd.toml is read, every command is checked: the key must be a lowercase name that is not built in, the dimension must exist (and not propagate, for a state command), every state and value must be one your tags can place, and every transition must be possible by editing tags, from every way of writing its from states — also on lines that carry another command's tags, such as @Task @Tell:

/home/me/notes/.streamd.toml is invalid:
  - commands.tell.transitions[0].to: no tags place tell = "done"; reachable: open, completed, cancelled; did you mean "completed"?
  - commands.task.transitions[1]: cannot go from open (@Task @Tell) to cancelled by changing tags; the tags that would do it change tell as well

The second one comes from a @Tell that is cancelled by @Cancelled, just like @Task: on a line with both, no tag cancels one without the other. A tag of the tell's own fixes it: with = "NotTold" instead of with = ["Cancelled", "NotDone"] on @Tell. Sharing @Done is fine as long as the tell has a tag of its own next to it (with = ["Done", "Told"]).

A mistake in [commands] only stops the configured commands, which then print the problems: streamd config tags list, streamd cache and the other built-in commands leave [commands] out and keep working, and the language server keeps running: with the last valid configuration if it had one, else without the configured commands (and showing the problems). Mistakes in the dimensions or tags still stop everything, since nothing can be placed without them.

The timesheet report calculates expected vs actual working hours based on the periods, showing:

  • Daily breakdown with expected/actual hours
  • Special day types (sick leave, vacation, holidays, flex days)
  • Warnings for missing entries and overlapping timecards
  • Monthly and cumulative balance

A day that cannot be computed — one that ends while you are still working (no closing @Break, except today), or one marked as two kinds of special day (vacation and holiday, say) — does not stop the report. It shows up as ⚠ Invalid with — for its hours, is left out of the monthly and cumulative totals (the report says Balance excludes N invalid day(s)), and is listed first among the warnings with the file:line of each entry to fix:

  Invalid days (not counted in the balance):
    - 2026-03-12 (Thu): day ends while working (no closing break)
        /notes/20260312-080000 Thursday.md:14

Invalid days before, after or between the periods are listed as well. After printing the report, streamd timesheet exits non-zero while any invalid day is left, so scripts notice.

The top-level [timesheet] table of earlier versions is no longer read; move its periods to [[commands.timesheet.periods]].

File commands

A command of type = "file" creates a file in your stream and opens it in your editor. streamd init configures two of them:

default_command = "daily"            # At the top of the file: what `streamd` alone runs

[commands.new]
description = "Create a new file"
type = "file"                        # Nothing else: a plain <timestamp>.md, every time

[commands.daily]
description = "Open or create the daily note"
type = "file"
placement = { file_type = "daily" }  # What its files are placed like
recurrence = { rule = "FREQ=DAILY" } # One file per day: open it if it is there
reminders = true                     # Optional: print the due reminders first
# template = "# "                    # Optional: what a new file contains; this is the default

placement is what the command looks for: a table of dimension and value, all of which a shard must have, as in the expect of a reminder. It also decides how new files are named: with the suffix of the one file type that, on its own, places a file like that — _daily here, so streamd daily creates 20260413-083000_daily.md. The command's --help names it. If no file type does, or several do, the configuration is rejected:

commands.daily.placement: the file types daily and standup all place { journal = "day" }, so the command cannot name its files; add what only one of them places

A placement only a tag could produce is rejected the same way. Without a placement, new files have no suffix, and every file of the stream is one of the command's, whatever its suffix: there is nothing a file has to be placed like.

recurrence holds a recurrence rule as its rule, as the since of reminders, and optionally the date it counts from (recurrence = { rule = "FREQ=WEEKLY;INTERVAL=2", start = 2026-01-05 }): every span from one of its occurrences to the next has one file. streamd daily opens the earliest shard of the current span — the one now is in — that is placed like placement, and creates a file only if there is none. That shard need not be a file of its own: with a tag @Day that places file_type = "daily", a section ## @Day … in another file is the daily note of its day, and is opened at its line. With rule = "FREQ=WEEKLY;BYDAY=FR" a streamd review opens the same file from Friday to Thursday.

Such a command takes a date: streamd daily 20260413 opens the note of that day. A new file for a span other than the current one is named with the start of the given day (20260413-000000_daily.md), one for the current span with now. Without a recurrence, a file is created every time.

A span can be shorter than a day: with rule = "FREQ=DAILY;BYHOUR=0,12" the morning and the afternoon each have a file of their own, by the clock of the configured timezone. A date alone then means the first span of that day; a time of it, written as in a file name (streamd daily 20260413-1400, or -140000), names the span that time is in, and a new file for it is named with that time.

reminders = true prints the due reminders of all reminder commands before the file is opened, when it is the file of the current span.

template is written into a new file as it is: template = "# @Review " starts every review with its tag.

streamd <key> append TEXT... adds a heading at the end of the file streamd <key> would open now, without opening an editor: streamd daily append @Task call Bob writes # @Task @143000 call Bob below an empty line. The time — and the date, if the file is of another day — goes after the tags the text starts with, so the new shard is one of now. A section of another file gets the line at the end of that file. If there is no file for now, one is created holding the line alone (# @Task call Bob; its name already says when), not the template. It prints Appended to <path> or Created <path>. Reminders are marked as done this way.

streamd <key> edit [WHICH] opens one of the command's files. What counts are the shards placed like placement, so streamd daily edit 2 is the daily note before the latest — a section a tag places is opened at its line — and streamd new edit goes back through all files. WHICH is one of:

  • a position: 1, the default, is the most recent, 2 the one before it, and negative numbers count up from the oldest (-1).
  • a date, 2026-10-10 or 20261010: the last one before that day began, so streamd daily edit 2026-10-10 is the note of the 9th, or the latest before it if that day has none. Eight digits are always a date.
  • a period to go back, as an ISO 8601 duration of whole numbers: P1D (a day), P2W (two weeks), P1M (a month), P1Y6M, PT12H (twelve hours), P1DT6H; lowercase works too. It opens the last one before now less that period: at 17:00, streamd daily edit P1D is the latest note from before 17:00 yesterday. Years, months, weeks and days go back on the calendar and keep the time of day, hours, minutes and seconds are exact.

If nothing is that old, it fails with No files before <date and time>.

streamd <key> -i browses the files of a file command, see Interactive files.

From the editor, the code action menu of the language server opens the file of now of every file command, as streamd <key> does — see Editor Integration.

Aliases of commands

A command of type = "alias" stands for another configured command, with some of its arguments:

[commands.edit]
description = "Edit a file by position"   # Optional; default: Alias of `streamd new edit`
type = "alias"
command = "new edit"

streamd edit then runs streamd new edit, and whatever follows it is handed on: streamd edit 3 is streamd new edit 3, and streamd edit --help shows the help of streamd new edit. The first word of command must be a configured command that is not an alias itself; the rest is checked when the alias runs, by the command it stands for. An alias can be the default_command. It has no view of its own in the interactive front-end, where the command it stands for has a tab.

An alias can instead run a shell script. Set exactly one of command and shell; an empty script is rejected:

[commands.sync]
type = "alias"
description = "Commit, pull and push the notes"
shell = """
git add -A
git commit -m "streamd sync" || true
git pull --rebase
git push
"""

streamd sync runs the script with $SHELL -c (sh when $SHELL is unset or empty), in the stream folder. STREAMD_BASE_FOLDER points to that folder, including for a streamd called by the script. Arguments are passed separately as $1, $2, …: use "$@" to forward all of them, including spaces. Even --help and -i go to the script. Input and output use the terminal as usual; streamd returns the script's exit code, or 1 if it is killed by a signal. Without a description, the help shows the script's first line.

Both kinds can be the default_command. A shell alias has no tab; when it is the default, streamd -i opens the picker as without a default. The LSP and the Android app never execute scripts. Android's alias form can edit either kind, with streamd command / Shell script above the text and a notice that scripts run only from the command line. Renaming a command updates command references and leaves shell code unchanged.

Reminders

A command of type = "reminder" is about one kind of shard you expect to write, and tells you when it is missing. Every reminder is a command of its own:

# Nothing in the Hausbau project for two weeks.
[commands.house]
type = "reminder"
description = "Work on the house"
expect = { project = "Hausbau" }
trigger = { days = 14 }

# No invoice since the last workday of the month.
[commands.invoice]
type = "reminder"
description = "Send invoice"
expect = [{ task = "completed", project = "Invoice" }, { file_type = "invoice" }]
trigger = { since = "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1" }

# Call XYZ every Sunday; `streamd call-xyz done` says you did.
[commands.call-xyz]
type = "reminder"
description = "Go, call @XYZ"
expect = { remind = "CalledXYZ" }
trigger = { since = "FREQ=WEEKLY;BYDAY=SU;BYHOUR=9;BYMINUTE=0;BYSECOND=0" }
append_to = "daily"

description is what the reminder says when it is due; without one it says its key.

What is expected. expect is where the shard must be placed: a table of dimension and value, all of which the shard must have. Values handed down from a heading count, so project = "Hausbau" matches everything below a @Hausbau heading. A list of tables means any of them. Shards are placed by their leading tags only, so a tag mentioned in the middle of a sentence does not count, and neither do shards dated in the future.

When it is expected. The trigger is a table with exactly one of:

  • days = 14 — at most 14 days after the last one. The reminder is due from the 14th day on.

  • since = "<RRULE>" — on or after the latest occurrence of a recurrence rule. The reminder is due from that moment until the shard is there. Some examples:

    since Due from
    FREQ=WEEKLY every Monday
    FREQ=WEEKLY;BYDAY=FR every Friday
    FREQ=MONTHLY the first of every month
    FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 the last workday of every month
    FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=1 every April 1st
    FREQ=DAILY;BYHOUR=0,12 every midnight and every noon
    FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9,14;BYMINUTE=30 09:30 and 14:30 of every workday
    FREQ=HOURLY;INTERVAL=4 every four hours, from midnight

    A reminder can be due several times a day: with BYHOUR=0,12, an entry of 11:00 is enough for the morning, and from 12:00 on another one is expected. The times are those of the clock in the configured timezone; FREQ can be anything from MINUTELY to YEARLY. A reminder that names times says them when it is due: due since 2026-10-13 12:00 (last 2026-10-13 11:00).

start (optional, a date without quotes) is the day a reminder begins to apply: nothing before it is reported, days are counted from it at the earliest, and a recurrence with an INTERVAL (FREQ=WEEKLY;INTERVAL=2) counts from it.

Marking it as done. append_to names a file command (it used to be called into_file; a configuration that still says into_file is rejected with the line to write instead). streamd call-xyz done then writes the tag the reminder expects into the file that command has for now — the daily note of today, created if there is none —, so the reminder is quiet until it is due again:

# @CalledXYZ @143000

The tag is the one that places what expect asks for on its own — a tag named like the value first, so here @CalledXYZ, which needs [tags.CalledXYZ] with dimension = "remind" —, and the time (with the date, if the file is of another day) makes the line one of now. The configuration is rejected if no tag can do that.

Where reminders show up.

  • streamd <key> says whether that reminder is due:
    Work on the house: due since 2026-09-15 (last 2026-09-01)
    Send invoice: due since 2026-09-30 (never)
    
  • The remind tab of the interactive front-end lists all that are due; streamd <key> -i opens it on that one.
  • A file command with reminders = true — streamd daily, after streamd init — prints the due reminders of all reminder commands before it opens today's note.
  • In the editor, a reminder that is due is a warning at the top of every file dated today.
  • In the app, on the Stream tab and under Reminders in All, with Done where it can be marked as done.

Upgrading from [[commands.<key>.rules]]. A reminder command no longer holds a list of rules: write one [commands.<key>] with type = "reminder" per rule, its name as the description. The old rules is rejected with that hint.

Activity

A command of type = "activity" shows on which days the shards of one or more queries — its series — were written: on which days did I run, climb, or both? How many days was I in the office, and how many only at home? streamd <key> prints it; streamd <key> -i opens its dashboard in the interactive front-end.

[commands.sport]
type = "activity"
description = "Training days"
intensity = "count"        # "count" (default) or "fixed"
mixed = "split"            # "split" (default), "first" or "most"
exclude_mixed = false
start = 2025-01-06         # optional bare date

[[commands.sport.series]]
name = "Run"
color = "#D97A2B"
query = { sport = "run" }

[[commands.sport.series]]
name = "Climbing"
color = "#2F5BEA"
query = [{ sport = "climbing" }, { sport = "bouldering" }]
  • series (at least one) are named queries, in an order that matters for mixed. A query is written like the expect of a reminder: a table of dimension and value, or a list of them of which any may match. A shard counts by its outermost match, so a sub-heading that inherits the placement does not count again. A series without color (#RRGGBB) takes the next colour of a fixed palette by its position: orange #D97A2B, blue #2F5BEA, then four more.
  • intensity: count draws a day brighter with its shards (1, 2, 3, 4 or more), fixed every day of a series alike.
  • mixed: how a day of several series is drawn — split among them, the colour of the first in the file, or of the one with the most shards that day.
  • exclude_mixed: count a day of several series apart from the days of each — "only in the office".
  • start: the first day that is tracked; without it, the day of the first match.

It is shown for a range of days, this month unless you name one, written in one of these forms (case and surrounding spaces do not matter):

Range Days
all everything tracked, from the start to today
2026 a year
2026-10 a month
2026-10-16 a day
2026-10-03..2026-10-17 both days and those between them; the earlier comes first
last 30 days the last 30 days up to today (last 1 day is today)

A shard belongs to the day of its moment in the configured timezone. Days after today and days before start are drawn but count for nothing. For the tracked days of the range there are the shards (each once, even if it belongs to two series — which is pointed out), the shards of each series, the active days, the shards per week, and the days of each series, of several and of none, both with the days of several counted for each series and without, as a share of the tracked days. A day of a series is drawn brighter at 1, 2, 3 and 4 or more shards (all of its series together), or always at full brightness with intensity = "fixed". A range of one day is drawn as that day, one of up to six weeks as a calendar, a longer one (and all) as a grid of weeks; a range steps back and forward by its unit — a day, a month, a year, or its own length.

streamd sport                        # this month
streamd sport 2026                   # a grid of the weeks of the year
streamd sport last 30 days
streamd sport all --exclude-mixed    # e.g. the days in the home office only
streamd sport shards 2026-10         # one line per shard, newest first
$ streamd sport
SPORT · Oct 2026 · 17 tracked days

 Mo  Tu  We  Th  Fr  Sa  Su
            ░ 1 ░ 2 ░ 3 · 4
◩ 5 · 6 · 7 ▒ 8 ░ 9 ░10 ·11
◩12 ·13 ░14 ░15 ▒16 ·17 ┄18
┄19 ┄20 ┄21 ┄22 ┄23 ┄24 ┄25
┄26 ┄27 ┄28 ┄29 ┄30 ┄31
░▒▓█ Run  ░▒▓█ Climbing  ◩ Mixed  · None

15 shards · 11 of 17 days active · 6.2 per week

DAYS        days    %  shards
Run            7  41%       8  incl. 2 mixed
Climbing       6  35%       7  incl. 2 mixed
Mixed          2  12%          also counted above
None           6  35%          no series matched

░▒▓█ are 1, 2, 3 and 4 or more shards in the colour of the series, ◩ a day of two series, · a day without any, ┄ a day to come; today is underlined. With --exclude-mixed (or exclude_mixed = true) the table counts Run only and Climbing only apart from the mixed days; --include-mixed undoes the setting for one run. A single day (streamd sport 2026-10-16) lists its shards with their time, title, tags and file instead of a chart. A shard that belongs to two series is pointed out after the output. streamd sport -i takes the same range and flags.

In the interactive dashboard, boxed tiles show the totals above the calendar or yearly grid, the Days table and the latest shards. A single day shows its series totals and shards. Below 60 columns the tiles become one line; a narrow yearly strip keeps the newest weeks, moving its window to keep the cursor visible.

  • h/l move by day in the calendar, j/k by week; in the grid h/l move by week and j/k by weekday. gg / G select the first / last day.
  • Enter drills from grid to month to day; Backspace returns with the previous cursor restored. [ / ] step the range; stepping a drilled day keeps its parents. t opens the range picker: edit the range with Left/Right, Home/End and Backspace/Delete, or use ↑/↓ to fill it from quick picks. The dates and day count update as you type; Enter applies a valid range as a new root and Esc cancels. Other shortcuts type into the field while it is open (Ctrl-C still quits).
  • a opens all shards of the range, grouped by day. 1–9 filter to a series and 0 shows all. In the day and all-shards lists, j/k, gg / G and PgUp/PgDn select shards; e edits the selection and reloads afterwards.
  • x switches between including mixed days in each series and counting them apart, for this session. r reloads. J/K scroll the page; PgUp/PgDn scroll the dashboard. ? and the shared front-end keys work as in the other views.

The names of the series must be there and differ, and every dimension and value of a query must be one the configuration places, as for a reminder. The editor completes the fields, the dimensions and values of a query and the choices of intensity and mixed, and a renamed tag or dimension is renamed in the queries too. The app has their screen and their form: the description, the series as numbered cards (a colour, a name, the lines of the query joined by "or", moved up and down since the order decides mixed days), the brightness and the mixed days with example days drawn as the settings draw them, Exclude mixed days and Tracking starts.

Shell Completions

streamd completions <shell> includes the commands your .streamd.toml configures, with their subcommands and flags. Since those depend on the configuration, evaluate the script when the shell starts — as the Home Manager module does — or regenerate it after editing [commands]:

eval "$(streamd completions bash)"   # in ~/.bashrc; likewise zsh

The scripts in the .deb package are generated without a configuration and only complete the built-in commands. Generating completions never fails and prints nothing but the script, even when the configuration is missing or invalid.

Usage

Files are opened in Zed when streamd is run from a Zed terminal and a zed, zed.exe or zeditor launcher is on $PATH — in that case the file lands in the Zed you are already working in, which takes precedence over $EDITOR (a terminal editor nested in Zed's own terminal is rarely what you wanted). Everywhere else $EDITOR is used, falling back to vi (notepad on Windows). $EDITOR may carry arguments (code --wait, emacsclient -t); streamd knows how the common editors take a line number (+7 file for vi, Emacs and nano, --goto file:7 for VS Code and its forks, file:7 for Zed, Sublime Text and Helix) and gives any other editor just the file.

On NixOS no setup is needed either: nixpkgs installs Zed's launcher as zeditor (zed is the ZFS event daemon there), and streamd looks for that name first.

On WSL2 no setup is needed: if $PATH has no launcher (the Windows installer's "Add to PATH" step is optional, and a remote Zed terminal inherits nothing from the Windows shell), streamd looks in the Windows installation directories itself — Program Files\Zed\bin for an all-users install and %LOCALAPPDATA%\Programs\Zed\bin for a per-user one, on whichever drives WSL has mounted. Should Zed live somewhere else entirely, set EDITOR to its full path, e.g. /mnt/c/Program Files/Zed/bin/zed.exe.

The POSIX zed wrapper is preferred over zed.exe when both are present, because it supplies --wsl <user>@<distro> itself; streamd adds --wsl <distro> only when invoking a .exe directly.

Zed is opened with --wait where streamd needs to know when you are done, and jumps to the task's line for streamd task edit N.

Running streamd runs the default_command — with the streamd init configuration, it opens today's daily note. streamd new creates a file named with the current time and opens it; the file keeps that name.

Running streamd task finds all tasks in one of the open_if states (open or waiting) and displays them numbered in your terminal; those in a state other than the first of open_if are marked with it, like [waiting]. A task in any other state is done. Tasks with future dates are hidden by default: --due 7d includes those up to the end of the seventh day from today (any number of days, --due 0d is the rest of today), --due any (or --show-future, its short form) those of any date, and --due now is the default; file timestamps are read in the configured timezone. Tasks are sorted by date with oldest first (task 1 is the oldest). Trailing blank lines of a task are left out, and on a terminal the headings are bold and the tags coloured as in the interactive mode below: the tags in the text, the state and the project in the heading (NO_COLOR turns that off).

You can quickly edit or complete tasks by number:

  • streamd task edit 1 opens task 1 in your editor at the correct line
  • streamd task done 1 3 marks tasks 1 and 3 as done by editing their tags; if one of them cannot be edited, no file is changed

Inside the stream folder (or a subfolder), the paths in the output are relative to where you are, so they can be copied straight into your editor; elsewhere they are absolute. This goes for the invalid days of streamd timesheet as well.

Interactive front-end

streamd -i (or --interactive) opens one terminal UI for all the commands of your .streamd.toml (aliases aside), starting with a picker to choose one; the default_command is selected in it, so Enter opens that one. streamd <key> -i opens the same front-end directly on a command, with the flags of that command (streamd task -i --project=Hausbau, streamd timesheet -i --decimal).

 task │ tell │ timesheet       4 items · project: Hausbau │ remind │ config
 <the view of the command>
 journal/20260928-091500 Einkauf.md:12
 j/k move  / search  enter transitions  e edit  u undo  tab switch  ? help  q quit

Every view has the same frame. The first line is the tab bar: your commands in the order of the configuration (the reminders share one tab, remind), the one that is shown highlighted, and at the right what it shows in a few words, then the built-in tabs remind and config. On a narrow terminal the summary is cut, and the bar scrolls to the active tab, with ‹ and › where tabs are hidden. Below the view is the file and line of the selection, and the last line is the key bar: the most used keys, the keys of a command that is not complete yet, a message, or a prompt.

These keys are the same everywhere:

Key Action
Tab / Shift-Tab Go to the next / previous command (wraps around)
: Pick a command from a list: j / k, gg / G, Enter opens it, Esc or q closes the list
e Open the selection in your editor and reload once you close it
r Reload from disk
? Help: the keys of the view, then these
q, Esc, Ctrl-C Quit

A view is loaded when you first go to it and keeps its state (selection, filter, search, unit) while you look at another one; coming back reads the stream again, as r does. While a view has a prompt or a popup open (the search, the transitions, the help), it gets every key, so Tab and : do nothing there. If a view cannot be opened — a timesheet command without periods, say — you stay where you are and the key bar says why.

streamd -i cannot be combined with a subcommand (streamd -i task is an error; streamd task -i is the way), needs at least one configured command, and, like every -i, a terminal.

One tab is not a command of yours: config, apart from the others at the right edge of the tab bar and last in the list of :. It is the tag maintenance, which streamd config tags maintenance -i opens directly — also without any configured command.

Interactive mode

streamd task --interactive (or -i) opens the tasks in the front-end, for browsing and moving them, in three panes like a file manager. It works for every state command, and combines with --due, --show-future and the partition flags such as --project=Hausbau:

 new │ daily │ task │ timesheet                                                      3 items
  PARTITIONS           │  TASK                                 │  PLACEMENT
  all               6  │  1  waiting  Angebot einholen         │  moment   2026-09-28 09:15
▶ Hausbau (project) 3  │▶ 2  open     Fenstermaße an Schreiner │  project  Hausbau
  Keller (project)  2  │  5  open     Rückmeldung Bauamt       │  task     open
  — (project)       1  │                                       │  TEXT
                       │                                       │▶ - @Task Fenstermaße an Schreiner
                       │                                       │    - Fenster 3 und 4 zuerst
 journal/20260928-091500 Einkauf.md:12
 h/l pane  j/k move  / search  enter transitions  e edit  u undo  tab switch  ? help  q quit
  • Left — the partitions: all, then every value the tasks have in the partitioned_by dimensions (here the projects), the one with the most tasks first, each with its number of tasks; — holds the tasks without a value. A task with a project and a person is counted under both. The counts are those of the last reload.
  • Middle — the tasks of the selected partition: number, state and the first line, without the tags that name its state and without markdown formatting. The numbers are the same as in streamd task.
  • Right — where the selected task is placed: its moment, then every dimension with its value. What its own tags place stands out; what it inherits is dimmed, and what the file type of its file places is marked (file). Below it, the whole task as it is written.

Tags are coloured and bold, and a tag (or its alias) always has the same colour; date and time tags such as @121500 are gray. A value on the left has the colour of its tag, so Hausbau there has the colour of @Hausbau in the text; the state is coloured the same way by its name, and turns gray once the task is done. Waiting tasks (in a later state of open_if) are yellow. Finished tasks stay in the list, dimmed and struck through, until you reload. If a file changed behind the TUI's back so that a task is no longer on the line it was loaded from, its transitions are refused until you reload.

streamd task -i --project=Hausbau starts on the row of that project instead of hiding the others, so they stay a k away. If the command is partitioned by more dimensions, the flag of the first one selects its row and the others filter, as in the listing: they are named at the top right.

Besides the keys of the front-end:

Key Action
h / l, ← / → Focus the pane to the left / right (it starts on the tasks)
j / k, ↓ / ↑ Move the selection of the focused pane (PgUp and PgDn move a page)
gg / G Select its first / last row (Home and End too)
5j, 3k, 2 PgDn A count repeats the movement
12G, 12gg Go to task 12, the number in the first column (row 12 in the other panes)
/ Search the focused pane: type a pattern and press Enter (Esc cancels)
n / N Go to the next / previous match (3n to the third)
Enter On a task: pick one of the transitions that apply to it. On the left: go to the tasks
e Open the task in your editor — in the text pane at the selected line — and reload once you close it
u Undo the last transition (only if its line is unchanged since)
r Reload from disk (renumbers, drops finished tasks, counts again)
f Reach further ahead: the tasks of the next seven days (· due within 7 days in the tab line), then of any date (· with future items), then up to now again

Keys work as in vim: a count or a single g waits for the key that completes it and is shown in the key bar meanwhile; Esc then only clears it instead of quitting, and switching to another command drops it. The search looks for the pattern as plain text (no regular expressions) in the focused pane — the labels on the left, every line of the tasks in the middle (tags included), the lines of the task on the right — and wraps around its end. It ignores case unless the pattern has an uppercase letter, and an empty pattern repeats the last one. In the transition picker, gg and G select the first and the last transition. A reload keeps the partition, the task and the pane you were on, as far as they are still there.

On a narrow terminal only the focused pane and the one next to it are shown, or the focused pane alone.

Interactive timesheet

streamd timesheet --interactive (or -i) opens the report in the front-end as three panes side by side, like a file manager: the months, the days of the selected month, and the timecards of the selected day. It works for every timesheet command.

 task │ timesheet                                                                            2 months · balance -23:15
  MONTH         EXPECTED  ACTUAL    DIFF  BALANCE │  DATE        DAY  EXPECTED  ACTUAL   DIFF  TYPE      │  TIMECARDS      HOURS
  March 2026       56:00   30:15  -25:45   -25:45 │  2026-04-01  Wed      8:00    8:30  +0:30            │  08:00 - 12:00   4:00
▶ April 2026      168:00  170:30   +2:30   -23:15 │▶ 2026-04-02  Thu      8:00    7:45  -0:15            │▶ 13:00 - 16:45   3:45
                                                  │  2026-04-03  Fri      8:00    0:00  -8:00  ⚠ Missing │
  Total           224:00  200:45  -23:15          │                                                      │
 journal/20260402-080000 Thursday.md:9
 h/l pane  j/k move  d decimal  e edit  r reload  tab switch  ? help  q quit

Every month has its expected and actual hours, their difference, and the balance up to and including that month; the Total line sums all months. d switches every number between hours and minutes (8:30) and decimal hours (8.50h); --decimal starts with the latter. A month with a day that cannot be computed is flagged ⚠, and such a day shows — and is left out of the totals, as in the printed report.

The timecards pane lists what the day is made of: each timecard with its length (today's card that is still running ends now and says (open)), the entry that makes it a vacation, holiday, sick or flex day, and what is wrong with it (the entries of an invalid day, overlapping timecards, a missing day). The line above the keys names the file and line of the selection, and e opens it in your editor: on a timecard the entry that started it, on a day its first entry. When you close the editor, the report is read again and the selection stays where it was.

Besides the keys of the front-end:

Key Action
h / l, ← / →, Enter Go to the pane on the left / right
j / k, ↓ / ↑ Move the selection (PgUp and PgDn move a page)
gg / G Select the first / last row (Home and End too)
5j, 3k, 12G A count repeats the movement, or names the row
d Hours as 8:30 or as 8.50h
e Open the entry in your editor and reload once you close it
r Reload from disk

On a narrow terminal only the focused pane and the one next to it are shown, or the focused pane alone. Unlike the printed report, the interactive mode exits with 0 even if invalid days are left; -i cannot be combined with --debug. A timesheet command without periods has no view: streamd timesheet -i explains how to add them, as streamd timesheet does.

Interactive files

streamd daily --interactive (or -i) shows the files of a file command in the front-end, in three panes:

 new │ daily │ task │ timesheet                                                        212 files
  DAILY                           │  SHARDS                     │  PLACEMENT
▶ 2026-10-10 Sat 08:00   Saturday │  ▾ # Saturday               │  moment     2026-10-10 08:00
  2026-10-09 Fri 08:00   Friday   │      # Saturday             │  file_type  daily  (file)
  2026-10-08 Thu 08:00 § Thursday │    ▾ ## @Hausbau Standup    │  project    Hausbau
                                  │▶     ▸ - @Task call Bob (1) │  task       open
                                  │        - @Task send invoice │  TEXT
                                  │      ## Notes               │▶ - @Task call Bob
                                  │                             │    about the roof
 20261010-080000_daily.md:5
 h/l pane  j/k move  space fold  / search  e edit  o new  d delete  r reload  tab switch  ? help  q quit
  • Left — what the command finds, newest first: the files placed like its placement, and the sections placed the same way in other files, marked §. A command without a placement, like new, lists every file. Each row shows the first line of what it found, its tags in their colours as everywhere else.
  • Middle — the shards of the selected one as a tree. Space folds and unfolds the selected shard; a folded one says how many shards it hides. The top two levels start open.
  • Right — where the selected shard is placed: its moment, then every dimension with its value. What its own tags place stands out; what it inherits is dimmed, and what the file type of its file places is marked (file). Below it, the text of the shard as it is written.

h / l (or the arrows, Enter) move between the panes; j / k, PgUp / PgDn, gg / G and counts move the selection of the focused one, in the text line by line. / searches the focused pane — the text of all listed files on the left, the shards of the selected file in the middle (a hit in a folded shard unfolds what hides it), the lines of the selected shard on the right — and n / N go to the next and the previous hit; the search ignores case unless the pattern has an uppercase letter. e opens the selection in your editor at its line and reloads once you close it. o opens what the command itself would open now, whatever is selected: with a recurrence the file or section the current span has already — today's daily note —, and only if there is none a new file, named with now. Without a recurrence it creates a file every time. d deletes the file of the selected match after asking — y confirms, any other key keeps it; a section goes with the whole file it is in, and the file is gone for good. r reloads the stream.

On a narrow terminal only the focused pane and the one next to it are shown, or the focused pane alone. -i cannot be combined with a date.

Interactive reminders

The front-end has one tab, remind, for the due reminders of all reminder commands, at the right next to config; a reminder command has no tab of its own. streamd <key> --interactive (or -i) on a reminder command opens it with that reminder selected. The reminders come in the order of the commands and the words streamd <key> prints them:

 task │ timesheet                                          2 due │ remind │ config
  Work on the house: due since 2026-09-15 (last 2026-09-01)
▶ Send invoice: due since 2026-09-30 (never)


 j/k move  d done  r reload  tab switch  ? help  q quit

j / k, PgUp / PgDn, gg / G and counts move the selection as in the other views. d marks the selected reminder as done, as streamd <key> done does, and the key bar says where it was written — or why it cannot be (no append_to). An empty list says nothing due.

Tag maintenance

Tags pile up: a typo here, a one-off there, a name you meant to configure. streamd config tags maintenance lists the unplaced ones — used, but neither configured nor part of a rule — with how often each is written:

@ChuckNorris  1
@cache        1
@rust         3

The descriptions of .streamd.toml are read with the notes: a tag one of them mentions is counted, and listed also if no note has it.

streamd config tags maintenance -i goes through them in the front-end, where they are also the tab config, at the right edge of the tab bar:

 task │ timesheet                         3 unplaced tags │ remind │ config
  UNPLACED       │  OCCURRENCES
  @ChuckNorris 1 │▶ 20260101-090000 Alpha.md:1  # @Task @rust
  @cache       1 │  20260101-090000 Alpha.md:3  @cache and @rust
▶ @rust        3 │  20260102-100000 Beta.md:1   lunch @rust
                 │
 20260101-090000 Alpha.md:1
 h/l pane  j/k move  enter act  e edit  / search  r reload  tab switch  ? help  q quit

It opens on a random tag, so that going through them does not always start with the same one. The left pane lists the tags, the right one every place the selected tag is written; h / l switch between them, and j / k, gg / G, counts, /, n and N work as in the other views. Enter asks what to do with the tag:

Key Action
x Remove @ — the tag becomes plain text everywhere: @rust is rust afterwards
a Add to dimension — type to narrow your extensible dimensions by name or key, pick one, then describe the tag (or leave it empty); it is added to .streamd.toml as streamd config tags add does
m Merge into — pick a tag that is placed (type to narrow the list, ↑ / ↓ to move), and the unplaced one is renamed to it everywhere
e Edit — open the selected occurrence in your editor (also e without the menu)

In Add to dimension, typing filters the list immediately, ignoring case unless you type a capital. ↑ / ↓, Home and End select a dimension, Enter chooses it, and Esc returns to the menu. Letters such as j, k and q are part of the filter.

The description wraps to fit the dialog and scrolls to keep its cursor visible. Edit anywhere with ← / →, Home / End, Backspace and Delete; Ctrl-← / Ctrl-→ move by word, Ctrl-W deletes the preceding word and Ctrl-U clears to the start. While typing an @Tag, configured tags and aliases appear below with their descriptions: ↑ / ↓ choose a suggestion and Tab completes it at the cursor. Enter always adds the tag with the description as typed; Esc returns to the filtered dimensions.

Removing and merging rewrite every markdown file that has the tag — and the descriptions of .streamd.toml that mention it, shown among the occurrences as .streamd.toml:<line>; nothing else of that file is touched —, so both ask first and say how much they touch (3 occurrences in 2 files); y goes ahead, any other key does not. There is no undo here — your stream is in git for that. A tag in code, an HTML block, the destination of a link or an e-mail address is left alone, as everywhere.

After each action the list is read again and another random tag is selected, until no unplaced tags is all that is left. A tag you added is placed for this view at once; the other tabs know it after a restart.

Local Cache

Streamd keeps a local cache of parsed stream files in an embedded graph database. Commands validate it with stat alone — comparing each file's size and modification time — and re-read only the files that actually changed.

On a local disk this makes little difference; parsing a few hundred small markdown files is already fast. It matters when the stream folder lives on a network-backed or synced share (OneDrive on WSL, for example), where opening each file is far more expensive than checking its metadata.

Location

Caches live under your XDG cache directory, not beside your notes:

~/.cache/streamd/<folder-name>-<hash>.grafeo

Each stream folder gets its own database, keyed by a hash of the folder's canonical path. Several streamd instances with different config.toml files therefore never share a cache, and two folders that happen to have the same name do not collide. Nothing is written into your stream folder, so there is nothing to add to .gitignore and nothing to exclude from file sync.

Set STREAMD_CACHE_DIR to override the location:

STREAMD_CACHE_DIR=/fast/local/disk streamd task

On the rare platform that offers no cache directory at all, streamd does not guess one — it skips the cache and reads your markdown directly. Set STREAMD_CACHE_DIR to get the cache back.

Behaviour

The cache is derived state and never authoritative. It is rebuilt from your markdown whenever it is missing, corrupt, or written by a different schema version, and any failure to use it — including another process holding it open — silently falls back to reading the files directly. Deleting it is always safe.

streamd cache            # where it is, how big, how many files
streamd cache rebuild    # discard and reindex
streamd cache clear      # delete it

Set STREAMD_NO_CACHE=1 to bypass it entirely for one invocation:

STREAMD_NO_CACHE=1 streamd task

To see what the cache does — hits, which files were re-read, when the whole cache is rebuilt, and why — set STREAMD_DEBUG=1. Events go to stderr; the LSP additionally always writes them to the editor's language-server log.

STREAMD_DEBUG=1 streamd task
# [streamd] cache: opened ~/.cache/streamd/notes-….grafeo (schema v6)
# [streamd] cache: sync scanned 812 files in 9ms: 811 hits, 0 added, 1 updated, 0 removed
# [streamd] cache:   updated ~/notes/20260925-090000 Standup.md

streamd config tags list and the LSP's @ completions read the list of used tags and markers by localizing the cached files, the same way every other command reads the corpus — there is no separate index to rebuild for them.

Editor Integration

streamd lsp starts a Language Server Protocol server that provides IDE features for your stream markdown files, and for the .streamd.toml that configures them. The server communicates over stdin/stdout and auto-activates only when a .streamd.toml file is present in the workspace root or a subfolder of it.

The workspace root is the first of the client's workspaceFolders that contains a .streamd.toml; if none does, the shallowest .streamd.toml up to three directories below a workspace folder is used instead (hidden directories, node_modules and target are skipped), so opening /x in the editor serves a stream in /x/y. The folder list comes from workspaceFolders, falling back to the deprecated rootUri/rootPath for clients that still send them. Adding or removing workspace folders, or creating a .streamd.toml (also in a subfolder), takes effect without restarting the server. If the client provides no root at all, the server logs a warning and stays inert — check your editor's LSP log if @ completions and task diagnostics are silently missing. If .streamd.toml is invalid when the server starts, it shows the problems and stays passive until the file is fixed — unless only [commands] is at fault, in which case it serves without the configured commands. While it is running, a .streamd.toml that stops being valid takes nothing away: the server goes on with the last valid configuration, commands included, and applies the new one once the file loads again, so saving a half-written line does not make diagnostics, completion or colours flicker. In .streamd.toml itself it keeps working either way: the problems are marked where they are, and completion and navigation go on.

Features

Feature Description
@ completions Suggests the tags from .streamd.toml with their description and rules, including tags that only appear in another tag's rules (e.g. @Break for @Timesheet), then every tag and marker already used anywhere in the workspace; conditional suggestions (e.g. @Done when @Task is on the line) come first. Newly used names become suggestible as soon as the file is saved
Hover Hovering over a tag shows what .streamd.toml says about it — its description, its rules (e.g. `@Task @Done` → `task = completed`), the dimensions they place in and its aliases — then what it places on this shard (**Here:** `task = waiting`, by `@Task @Waiting`, or that it places nothing because it is written in the text) and where the shard is: its moment (Moment: `Friday, 2026-10-09 14:30:00`, the date and time its tags, the shards around it and the file name give it — for a task, when it counts) and its dimensions, with the values it inherits (Project: `Hausbau` (inherited)) and those the file type of its file places (Journal: `day` (from the file type `_daily`: The daily note)). An alias shows the tag it stands for, a tag that only appears in another tag's rules (e.g. @Done) the rules that use it, a date or time what it reads as (@20261224 → Thursday, 2026-12-24), and a tag placed in no dimension says so
Temporal snippets @ followed by a digit offers YYYYMMDD / HHMMSS format snippets
Diagnostics File-name format warnings (R15); timesheet errors (overlapping timecards; unclosed days and conflicting day types on the offending entry's line), for every timesheet command; reminders that are due, as warnings; with warn_unplaced_tags = true, a hint on every tag placed in no dimension, also where a description of .streamd.toml mentions one; a hint at the start of a file whose _suffix is not a configured file type, naming the closest one (_dialy is not a configured file type, so it places nothing; did you mean _daily?)
Workspace task diagnostics Every open/waiting item of every state command (e.g. each open @Task) across all .md files in the workspace (not just open buffers) is reported as an Information-severity diagnostic, coded with the command's key, via LSP 3.17 pull diagnostics (textDocument/diagnostic, workspace/diagnostic) — shows up in Zed's diagnostics: deploy panel as a global todo list, and refreshes automatically on save or external file changes
Problems of .streamd.toml Every problem of the project configuration is an error right where it is in .streamd.toml — the unknown dimension on its then, the misspelt field on its key — while you type, and listed in the diagnostics panel also when the file is not open
Completion in .streamd.toml The sections after [, and after [[ the rules, transitions, periods and series of what you defined; the fields of the table you are in, with a line on what each does (for a command, those of its type); and the values: your dimensions, your tags (in with, and after @ in a description), the states your tags place (open_if, from, to, then), the values of a query, your commands, the command types and the choices of rounding, intensity and mixed, timezones. It works while the file is half-written and invalid
Tag colours Semantic tokens for every @Tag in markdown (dates and times as their own type), and for the tags .streamd.toml names; the editor's theme picks the colour. In Zed this is opt-in, see below
Document symbols Shard tree exposed as outline symbols
Transitions One quick fix per transition that applies to the shard on the line (e.g. "Mark as Done", "Mark as Waiting"), editing its tags as streamd <key> <subcommand> would; transitions without a subcommand are offered here only
Add to dimension For a tag on the line that is placed in no dimension, one quick fix per extensible dimension (e.g. "Add @ChuckNorris to Person"), adding it to .streamd.toml with an empty description to fill in — also in .streamd.toml itself, for a tag a description mentions. Likewise for a file whose suffix is not a configured file type, on its first line: "Add the file type _review to File Type" adds [file_types.review]
File commands In every markdown file and in .streamd.toml, the code action menu (in Zed ctrl-. / cmd-.) has one entry per file command: "Create the daily file", or "Open the daily file" once today has one. It opens what streamd daily would, creating the file with its template if there is none yet; a file just created that the editor cannot open is removed again. Offered by editors that can be asked to open a file (window/showDocument), as Zed can
Workspace symbols Search shards across all .md files; open/waiting items of the state commands are additionally indexed by their own text so they're findable by content
Go to definition From an @Marker to where .streamd.toml defines it: its [tags.<Name>] table, its alias entry, or the with that names it. Inside .streamd.toml, also from with, alias and an @Tag in a description to the tag, and from any place a dimension is named (dimension = "task", then = { task = … }, partitioned_by, placement) to its [dimensions.<key>] table, and from a value that is the name of a tag (placement = { project = "Hausbau" }) to that tag
References Find all occurrences of an @Marker across the workspace (whole tags only, so @Task never matches @Task-Force or me@Task.de; code is skipped), including where .streamd.toml names it; for a task in a partition, such as a project, only those in the same one. For a tag that places its own name (dimension = "project"), this includes where .streamd.toml names that value (placement = { project = "Hausbau" }). Works from .streamd.toml too, for tags, dimensions and values
Rename Rename an @Marker across all files, with the same matching as References, and in .streamd.toml along with them: its [tags.<Name>] table, its entries in alias and with, and where a description mentions it. A tag that places its own name ([tags.Hausbau] with dimension = "project") takes its value along: placement = { project = "Hausbau" }, and the same value in then, expect, open_if, from, to and values, is renamed too — only in that dimension, and not when an alias is renamed. Can be started from .streamd.toml as well. Renaming onto a tag that already exists merges the two — the editor asks first (@Done is already a tag. Drop the definition of @Task and use @Done everywhere?), and on Abort nothing changes; the question also says whether the values of the dropped tag become the name of the other one or stay. The prompt is pre-filled with the whole tag name (BuildingAHome.Doors, not only Doors), and a new name that is no tag name is rejected

Zed

Install the streamd Zed extension — it's the only supported way to get streamd running in Zed. It works for local Windows and Linux Zed, and for Zed Remote Development (SSH or WSL2 remotes) — the extension runs the wasm inside whichever host Zed is talking to and downloads the matching binary there automatically.

Once the extension is installed, no settings.json changes are needed: extension.toml already declares languages = ["Markdown", "TOML"], so Zed registers the streamd language server for Markdown files, and for .streamd.toml, on its own. In every other TOML file the server stays silent, so it does not get in the way of a TOML language server such as Taplo.

To have tags coloured, turn on semantic tokens for the two languages — Zed leaves them off by default — in ~/.config/zed/settings.json. "combined" keeps the usual highlighting and adds the tags on top:

{
  "languages": {
    "Markdown": { "semantic_tokens": "combined" },
    "TOML": { "semantic_tokens": "combined" }
  }
}

Tags are tokens of the type decorator, dates and times (@20260101) of the type number, which your theme already has a colour for. To choose one yourself, add a rule (the first matching one wins):

{
  "global_lsp_settings": {
    "semantic_token_rules": [
      { "token_type": "decorator", "foreground_color": "#d19a66", "font_weight": "bold" }
    ]
  }
}

The rule applies to every language server's decorator tokens, since Zed has no per-server rules; the extension cannot ship it, as it does not define the Markdown and TOML languages itself.

If you want to point Zed at a specific streamd binary instead of letting the extension resolve one from PATH (or download it), add to ~/.config/zed/settings.json:

{
  "lsp": {
    "streamd": {
      "binary": {
        "path": "streamd",
        "arguments": ["lsp"]
      }
    }
  }
}

This only overrides which binary the already-registered streamd adapter launches — it does not register the server by itself, so it's only useful with the extension installed.

Zed Extension

The extension resolves the language server binary in this order:

  1. An existing streamd already on PATH (e.g. installed via Nix, apt, or cargo install) is used as-is.
  2. Otherwise, it downloads the matching prebuilt binary for the current platform (Linux x86_64 or Windows x86_64) from the releases page into Zed's private extension directory, and reuses it on subsequent starts.

macOS and non-x86_64 platforms: no prebuilt binary is published yet. Install streamd manually and make sure it's on PATH — otherwise Zed will show a clear "no precompiled binary available" error instead of starting the language server.

1. Download the extension from the releases page:

streamd-zed-extension-X.Y.Z.zip

2. Extract the zip to a permanent folder, e.g. C:\Users\<you>\zed-extensions\streamd-zed-extension\ on Windows or ~/zed-extensions/streamd-zed-extension/ on Linux:

The folder must contain extension.toml and extension.wasm.

3. Install the extension in Zed via the command palette (Ctrl+Shift+P):

zed: install dev extension

Point Zed to the extracted folder. For Remote Development, install it in the local Zed client — Zed propagates the extension to the remote host automatically.

4. Verify by opening a Markdown file inside a directory that contains .streamd.toml — @ completions and diagnostics should become active.

Neovim (nvim-lspconfig)

1. Register the server — add to your Neovim config (e.g. ~/.config/nvim/init.lua or a plugin file):

local lspconfig = require('lspconfig')
local configs = require('lspconfig.configs')

if not configs.streamd then
  configs.streamd = {
    default_config = {
      cmd = { 'streamd', 'lsp' },
      filetypes = { 'markdown' },
      root_dir = lspconfig.util.root_pattern('.streamd.toml'),
      single_file_support = false,
    },
  }
end

lspconfig.streamd.setup {}

The server activates automatically when Neovim opens a Markdown file inside a directory that contains a .streamd.toml file.

2. Using LSP features — standard Neovim LSP keymaps apply (:help lsp):

Action Default keymap Notes
Trigger @ completions <C-x><C-o> (insert mode) Or via your completion plugin (nvim-cmp, blink.cmp, …)
Show diagnostics for current line <C-w>d / gl File-name format warnings, timesheet errors
Jump to next / previous diagnostic ]d / [d Navigate between warnings
Code actions (transitions such as "Mark as Done", "Add @Name to …") <leader>ca (Neovim ≥ 0.10) Place cursor on a line with @Task, or with a tag placed in no dimension
Rename marker across all files <leader>cr / grn Renames the @Marker under the cursor everywhere
Find all references to a marker grr / <leader>fr Lists every occurrence of @Marker across the workspace
Document outline (shard tree) :lua vim.lsp.buf.document_symbol() Or via Telescope: :Telescope lsp_document_symbols
Workspace symbol search :lua vim.lsp.buf.workspace_symbol() Or via Telescope: :Telescope lsp_workspace_symbols

Note: default keymaps (grn, grr, <C-w>d, ]d/[d) are available from Neovim 0.10+. On older versions use :lua vim.lsp.buf.* commands or set up keymaps manually in your on_attach callback.

VS Code (tasks.json / manual)

Use any extension that lets you configure custom LSP servers, pointing cmd to streamd lsp.

Development

Structure

The repository is a cargo workspace of three crates:

  • crates/streamd-core is the logic, from the text of a stream to what is shown of it: parsing, localization, the project configuration, what each configured command computes, the tag maintenance. It never reads a file, the clock or the environment and never prints: files come through the StreamStore trait its host implements, the moment as an argument. That keeps it usable outside a terminal, and streamd_core::api is the way in for such a host.
  • crates/streamd-cli is the streamd binary: the command line, the interactive views, the language server, the cache and the file system.
  • crates/streamd-ffi is the bindings of the core for Kotlin, through UniFFI: the facade of the core as objects and records, and the StreamStore as an interface the app implements. It converts and nothing else; the streamd binary does not contain it.

The border is checked by nix flake check: a test fails if the core reaches a terminal, LSP, runtime or file-system crate or UniFFI, if the CLI reaches the bindings, or if the core or the bindings name the file system, the clock or a printing macro in their sources (crates/streamd-core/tests/architecture.rs), and clippy disallows the same for the core (crates/streamd-core/clippy.toml).

nix develop -c cargo test                    # all crates
nix develop -c cargo test -p streamd-core    # the logic alone
nix develop -c cargo test -p streamd-ffi     # the conversions of the bindings
nix flake check                              # what CI runs, with the Kotlin test of the bindings

Using the core from Kotlin

nix build .#streamd-android (on x86_64 Linux) gives what an Android project takes: jniLibs/<abi>/libstreamd_ffi.so for arm64-v8a, armeabi-v7a and x86_64, and kotlin/ with the generated sources of the package de.konstantinfickel.streamd. The app needs JNA (net.java.dev.jna:jna:<version>@aar) next to them. nix build .#streamd-kotlin gives the Kotlin alone, and nix build .#streamd-ffi the library and the generator for the machine that builds.

The app implements the files, the core does everything else:

class FolderStore(/* … */) : StreamStore {
    override fun list(): List<FileRef> = TODO("every markdown file: its id (a content URI) and its name")
    override fun read(id: String): String = TODO()
    override fun write(id: String, text: String) = TODO()
    override fun createNew(name: String, text: String): String = TODO("the id of the new file")
    override fun remove(id: String) = TODO()
    override fun readConfig(): String? = TODO("the text of .streamd.toml, null without one")
    override fun writeConfig(text: String) = TODO()
}

val project = Project.load(store)                 // throws StreamdException
val stream = project.loadStream(store).stream
val tasks = project.stateItems(stream, "task", emptyList(), false, Instant.now())
val edit = project.transitionItem(store, stream, "task", tasks[0].number, firstLine, "completed")
  • Nothing is asynchronous: the store is called on the thread that called in, so call from a background dispatcher (Dispatchers.IO).
  • A file is known by its id, which only the app interprets, and its name, which the date and the file type are read from.
  • The core takes no clock: now is an Instant the app gives. Dates and times of day are java.time.LocalDate and LocalTime.
  • A store that throws a StoreException — or anything else — fails the call with a StreamdException.Store; every StreamdException has the wording of the core in reason.
  • Places in a text — of .streamd.toml (ConfigProblem, Completion, Edit) or of a markdown file (tagOccurrences) — are TextRanges in UTF-16 units, the indices of a Kotlin String.
  • project.tags() is every name a tag can be written as, with the line that says what it is: what an editor offers after an @.
  • project.stateBoard(stream, key, partition, showFuture, now) is a state command with its items in every state, the closed ones too: a StateColumn per state, an open item with the number stateItems gives it, a closed one without. project.transitionShard(store, stream, key, fileId, startLine, line, to) moves an item by where it is, so a closed one can be reopened; project.partitionValues(…) counts the values of the partitioned_by dimensions; project.stateSummary(…) is shardSummary without the tags that only say the state.
  • project.timesheetReport(stream, key, now) is the report of a timesheet command by month with the timesheets it is made of; a Timecard has the whole minutes it counts as, by the rounding of the command. project.timesheetWeek(stream, key, date, now) is the ISO week of a date of that report: the days the report has from its Monday to its Sunday, and the totals of those that count. project.timesheetReportWeek(stream, key, date, now) gives both at once, computed once.
  • project.shardSummary(stream, fileId, startLine, fileText) is a shard as a list shows it: its title (the first line without its markdown, with the tags left in it and where each is) and its preview (the first paragraph after it as plain text, without tags). fileSuffix(name) is the _<suffix> of a file name.
  • Project and Stream never change: a changed file gives a new stream (stream.withFile(…)), a changed configuration a new project.

The test of the bindings is itself Kotlin (crates/streamd-ffi/tests/kotlin/StoreTest.kt): it implements a store over a map and runs on the JVM in nix flake check.

The Android app

android/ is an Android app on those bindings: Kotlin, Jetpack Compose and Material 3 Expressive with the dynamic colours of Material You (taken from the wallpaper, light and dark with the system) and the fonts of the system, its monospace one for what is written as it is in the files, application id de.konstantinfickel.streamd.app, for Android 13 (API 33) and later — the Git of the app, JGit, reads files in a way Android has had since then only. Its version is the version of the workspace. Its launcher icon, a paper scroll with a tag on it, is adaptive and has a monochrome layer for themed icons; its sources are the SVGs in android/icon/.

It holds one stream: the notes of a Git repository, cloned into storage that no other app reads (files/repository of the app). The core reads the stream from the clone: .streamd.toml and every markdown file below the notes folder, hidden folders left out. It has the repository, the stream, the commands, the note and the editor:

  • Until a repository is connected, the app opens with the Repository screen (below).
  • A bar at the bottom with the tabs Stream, up to two pinned commands of .streamd.toml (by their keys) or the pinned Config, All and Changes. The Changes tab commits and pushes what the phone changed (below); a dot on it says that there is something to look at: changed files, commits not pushed yet, a merge that waits, or a problem the check of the stream found. Until you pin something, the pinned commands are the default_command if it is a state, timesheet or activity command, then the first state and the first timesheet command. The tab of a state command shows how many items are in its first open state.
  • All: every command of .streamd.toml by its type, each with the icon of its type, as in the bottom bar. Lists has the state commands, each with how many items its open states have (7 open · 2 waiting); Time the timesheet commands with what this week is over or short (-7h 10m this week); Activity the activity commands with their active days this month; New note a chip per file command, which opens Capture with it chosen; Reminders what is due, each with its Done as on the Stream tab; Configuration the Config screen (below). A list, timesheet or activity command opens its screen, and the pin beside it puts it into the bottom bar or takes it out: two fit, and pinning a third replaces the one pinned longest ago. What is pinned is remembered per repository; a pinned command that a pull removed from .streamd.toml simply leaves the bar. The search in the header filters the commands by key and description. Aliases are not listed.
  • Config, the configuration of the project: everything .streamd.toml defines, a tab for each part, in the order things build on each other — Dimensions, the Tags and File types that place in them, the Commands that work on them, and General (the timezone, the default command, whether unplaced tags are pointed out) —, each entry leading to its form, each tab with a button that adds to it. Swipe to the side to change the tab. Tags and file types are sorted by name (regardless of case), and their tabs have a scroll handle that moves by rows and shows the name of the first entry on the screen while it is held; dimensions and commands keep the order of the file. The search filters every tab, and each tab says how many of its entries match. It is opened from All and can be pinned to the bottom bar like a list or a timesheet. A form writes into .streamd.toml and keeps its comments and its order; it only saves what still loads (what is wrong is said in the form), and never over a file that changed since the form was opened. In the form of a timesheet, leave the end of a period empty to keep it open; Add a period after an open one closes it yesterday and starts the new one today. The form of a reminder has its expect, its trigger, its start and Done into, the file command its Done writes into. The form of an activity has its series as numbered cards — a colour (tap it to pick one of the palette or type #RRGGBB; without one a series takes the colour of its place), a name and its query, each line a chip, joined by "or", with Or to add one —, moved up and down, since the order decides how a mixed day is drawn (moving one keeps its colour); Add starts a new series in the next colour. Below them Brightness (By count or Fixed) and Mixed days (Split, First or Most), each with what it does, and a row of example days in the colours of the series, drawn as these settings draw them (without a second series, the mixed ones are left out); then Exclude mixed days and Tracking starts. It is not saved without a series, or with a series without a name or a query, or with a colour that is none. In a description, the tags it mentions are coloured as in a note (grey if nothing places them), and typing @ offers the tags of the project, as the editor of a note does. Typing over the key of a dimension, a file type or a command renames it wherever the file names it; renaming a file type renames no file. A dimension cannot be removed while a tag, a file type or a command names it, a command not while an alias or default_command does. The tags that are placed in no dimension are listed first on the Tags tab — those of the notes, and those a description of .streamd.toml mentions —, and saving the form of one places it. Edit as text (the pen in the header) opens the file itself, with what is wrong underlined and listed while you type, the unplaced tags of its descriptions after it as hints, and what can be typed at the cursor offered above the keyboard; it is also how a .streamd.toml that does not load is fixed, from the screen that says so.
  • A tag chip — in the stream, a list, the timesheet or a note — opens the page of that tag (a date or a time written as a marker, like @081500, is no tag and gets no chip): its form if .streamd.toml defines it, what it is another name of, or which tags it says more about. Typing over the name of a tag renames it in .streamd.toml and in every note, after a question that says how often it is written, in the notes and in the descriptions of .streamd.toml; a name that is a tag already is asked about once more and merged into. Removing a tag takes its definition out of .streamd.toml only: the notes keep it, unplaced. With Commit every saved note switched on, a change of the configuration is a commit of its own, and a rename one commit of everything it touched.
  • Stream, the first tab: every file of the stream as a card, newest first, under a header per day (Today, Yesterday, then the weekday and the date). A card shows the time, the title (the first line of the file), the start of its text, its tags, how many shards it has and its file type (_daily). Above the cards are the reminders that are due — each said once, with Done at its bottom right where it has an append_to (it writes what the reminder expects, as streamd <key> done does); more than two fold into one line — and the files that could not be read. The search in the header filters the cards by title, text and tags — case-sensitive only if you type an upper-case letter —, the menu beside it leads to the Repository screen and reads the stream again. A card opens the note. While you scroll, a date handle appears at the right edge. Its track runs from the newest to the oldest day by elapsed time, regardless of how many cards each day has; dragging snaps to the nearest day with cards in the current search and filter. Hold it to see month marks (quarters or years over longer spans), tap a mark to jump, and follow the large date and weekday preview (Today or Yesterday with the full date below for those days). A month change gives a haptic tick. Capture hides while you hold the handle. The handle fades after 1.5 seconds at rest; touching the narrow right edge reveals it again. TalkBack announces the date, steps between days with cards, and offers previous and next month actions.
  • The note: one file of the stream. Read renders its Markdown shard by shard — a shard below another is set behind a rule and numbered (shard 1.2), the tags of a shard stay in its text, each on its tint, and those at the start of a shard are chips under its first line too —, Markdown shows the file as it is written, with its tags coloured. Tap a shard to select it; the sheet at the bottom then says when it is from and where it is placed: one row per dimension with its value, and "from the file type" or "inherited" where the shard does not place itself there. If the shard is an item of a state command (@Task), the sheet has a button per transition from its state (Done, …), with Undo for a few seconds afterwards. Edit opens Capture on the file at that shard. The header shares the text of the file with another app and, in its menu, renames the file — in the folder it is in, to a name ending in .md that is not taken — or deletes it after asking. A file whose name is wrong — no date, a file type .streamd.toml does not know — says so above its text.
  • The screen of a state command (task after streamd init; any other from All or the menu behind its title): its open states as tabs with their counts, one row of chips to filter by — All and the five values of its partitioned_by dimensions with the most items, counted over the open items, so a project whose tasks are all done is not among them; a chip shows that value alone —, and the items with their #number, date and file; an item dated after now says how far ahead it is (2026-11-02 · in 8 days), its date set off in the accent colour; the tags a line starts with are shown as chips below it, the tags within the text stay where they are, coloured as in the editor. Show closed in the menu (⋮) adds the closed states as tabs and counts their items in the chips too, which streamd task does not list; Hide closed takes them away again. The filter button beside the search opens a sheet with the Due range — Until today (the default: nothing dated after now), Next 7 days (up to the end of the seventh day from today) or Any date — and every value of every partition dimension, with its dimension, its count within that range and TOP 5 on those the chips offer; tick several to see the items that have any of them. The search of the sheet matches a value or the name of its dimension, in any case and with or without @. Show N tasks applies it, Reset goes back to the default, and closing the sheet any other way leaves the filter as it was. While the filter is more than a chip can make, the button has a dot, a range other than Until today is a chip of its own at the start of the row (it opens the sheet), and a chosen value outside the top five has its chip too. The filter is kept while the app runs, per command. A swipe to the side on the items leads to the state beside, like a tap on its tab. A checkbox finishes an item, a button moves it to the next open state, the menu beside it has the other transitions; what was done is shown with its tags (@Task → @Done) and an Undo. A closed item can be moved back if .streamd.toml has a transition from its state.
  • The screen of a timesheet command (timesheet after streamd init; any other from All): its report by week, starting on the week of today. The arrows beside "Week 41 · 5 – 11 Oct" lead to the weeks before and after, as far as the report has days. A summary has what was worked and expected in the week, the difference, and the balance of the whole report. Below it every day has a bar, with its time above and a mark at the expected time; a day without an entry is a dashed outline labelled miss, a day that cannot be computed is red, a vacation, a holiday, a sick leave or a flex day says what it is. Tap a day to see it: its timecards with their times, the line that opened each (its leading tags as chips, the others coloured in place) and its minutes — a tap opens the note at that line —, Log hours on a missing day, which opens Capture, and what is wrong with an invalid day, with the lines to look at. A day before the first period of the command is no day of the report, as in streamd timesheet.
  • The screen of an activity command opens on All time, scrolled to the newest weeks. The grid shows Monday-first weeks; tap a day to open its month, then tap a date to see its events as dots on a 24-hour timeline, one lane per series. Events too close to tap apart become one larger dot with their count, labelled with their span (06:40–06:52); a wider screen keeps more of them apart, and labels that would overlap move below the line. Tap a dot to open its note at its line, or a cluster to choose one of its shards from a menu. Back retraces those ranges; the range arrows start a new root. The tiles show shard totals, active days and shards per week; on a day they show each series with its event times. Series colours have four intensity levels (one with Fixed brightness, and the legend then shows the one swatch); mixed days use two halves or three or more wedges, each as bright as the shards of its series that day (10 of one and 1 of another give a bright and a dim part), future days are outlines, and today has a ring. The legend, up to five latest shards (tap to open the note), and the Days table stay in every view. Exclude mixed days in ⋮ switches the Days table and its shard counts without loading again; it starts from the command setting and is kept while changing ranges. Edit command opens the form of the command, with its series, colouring and statistics. Tap the range label for the Time range sheet: type a range with live validation, use the All/Days/Month/Year pickers or a quick pick, then Apply to start a new root. On Days, tap one day, then another to make a range; the calendar opens where the current range ends. Dismissal cancels. Show all N shards opens a newest-first list grouped by day, filtered by series and loaded in pages of 50. Rows show their detail and open the note. Show shards in stream in ⋮ selects all matching files, including those beyond the first page; its dismissible chip persists across tabs and intersects text search. Its All entry and pinned tab show the active days this month.
  • Capture, opened with the button of the Stream tab: a markdown editor for a new file of one of the file commands of .streamd.toml (new, daily, …; chosen by the chips at the top, the first one to begin with). The file is the one streamd <key> would open now: if it exists it is edited, otherwise the editor starts on the template and shows the name the file gets. Tap the name to change it: a new file is created under the name you type, an existing one is renamed when it is saved. Choosing another chip keeps what you typed: it goes on to another new file as it is, and to the end of an existing one; only an existing file you changed other than by adding to it, or renamed, is asked about first. Nothing is created or renamed before Save. While a word starting with @ is typed, the tags of .streamd.toml that start with it are offered with what each means; the bar above the keyboard puts in a heading mark, an @, a list mark, or — the clock — moves the shard the cursor is in to now: its date and time markers are replaced by those that make it read as now (# @124000 Call becomes # @130000 Call, with the date as well when it is another day). On the first line of a file, which would move the whole file, the time is put in at the cursor instead. If the file changed underneath since it was opened (a pull), the app asks before it writes over it.
  • Working time: while a card of today runs, the Stream tab has a banner above the reminders — "Working since 09:12 · Time Tracking", how long so far, in the calm secondary colours rather than the amber of a warning — which opens the timesheet, and the timesheet screen says "Working since 09:12 · 2:31" below its title. Stop writes the break as streamd timesheet break does, and commits and pushes as a save does; it is there where the command has an append_to. An ongoing notification per running timesheet counts the time on its own, opens the timesheet when tapped and has Stop working, which writes the break without opening the app. A card written on the desktop shows up at the next background check (after a pull); the notification goes away with the break, and at midnight.
  • Notifications: a reminder that comes due is also a notification, one per reminder, with Done where it has an append_to — it writes, commits and pushes as the Done of the Stream tab, without opening the app. It rings when the reminder comes due and once a day after (from 08:00 on) while it stays due, and goes away once the reminder is done, also when it was done on another machine: the app checks in the background when the next reminder comes due and every three hours, pulling first when there is a network. The app asks for the permission once, when the project first has a reminder; Android may deliver a notification a few minutes late.
  • A .streamd.toml that is invalid is shown with every problem of it, and nothing else until it is fixed — by a pull, if it was fixed elsewhere; a stream without one works, without commands.

Connecting a repository

The Repository screen is a list of settings in three groups; a tap on a row opens it in a dialog.

  • Repository: the Remote URL, its prefix — https:// or ssh:// — chosen from a menu in front of what you type (pasting a whole URL chooses it for you; under ssh:// the short form git@host:me/notes.git is kept as it is, and a plain http:// URL is taken as https://), the branch (main) and the notes folder inside the repository (/, the whole repository).
  • Sign-in, which the prefix decides: for ssh://, the SSH key — the app makes an ed25519 key of its own when it first starts and shows its public half (an ECDSA key made by 0.35.1 is kept and works on). Copy public key puts it on the clipboard: add it to the repository as a deploy key (on Forgejo: Settings → Deploy keys; with write access, if the app is to push later). Import key takes a private key file instead — as OpenSSH writes it, without a passphrase. For https://, the username and token (on Forgejo: Settings → Applications, with access to the repository).
  • Sync: Pull when the app opens (on), the commit author — the name and e-mail your commits are by; the first clone fills it in with whoever wrote most of the commits of the branch, if it is not set yet —, Push right after committing (on) and Commit every saved note (off), both described below with the Changes tab.

Before a repository is connected, what you set is cloned by the button at the bottom. Once connected, a change is used as soon as its dialog is done, and a switch as soon as it is flipped.

Clone and open stream clones the branch, shows how far it is and can be cancelled. The first time the app connects to an SSH host it shows the fingerprint of the key of that host and asks; an accepted key is kept, and a host that later shows another key is refused. A clone that fails says why, in the words of Git, and leaves nothing behind.

A pull — when the app opens, or with the Pull button — moves to what the remote has. Where both the phone and the remote have changed, it merges: what you changed and did not commit yet is committed first, then the remote is merged in a merge commit ("Merge origin/main"), which is pushed if pushing is on. Without a name and an e-mail to commit by, a pull only moves where nothing was committed on the phone.

Pulls the app makes on its own — the background check of the reminders, and the one after a saved note the remote rejected — merge only what joins cleanly and never commit for you; where the two sides changed the same lines they leave everything as it was and say "This phone and the remote changed the same lines. Pull here to resolve them."

Merge conflicts. If a pull of yours finds the same lines changed on both sides, the Changes tab shows the merge waiting ("Pull needs your help.", the branch · merging) and lists the conflicted files. Tap one to resolve it: each conflict shows the lines of this phone and of origin/main, and you Keep phone, Keep origin or Keep both (yours first — usually right for a stream of notes); Change chooses again, and Mark resolved writes the file. Edit by hand opens the editor on the file with Git's markers (<<<<<<< this phone … ======= … >>>>>>> origin/main) tinted: delete the marker lines, keep the lines you want and save — a file without markers is resolved. Finish merge (and push) commits once every file is resolved; Abort puts everything back as it was before the pull, and nothing is lost. Commit every saved note waits until the merge is finished. The merge waits across a restart of the app.

Changes is where your notes go back to the repository. A card says whether the phone is up to date with origin/<branch>, ahead or behind, when it last pulled, and when it last pulled. Below that, the check of the stream runs every time the tab is shown: "Stream is valid" with how many notes and tags it has, or the problems in groups — files that cannot be read or still hold the markers of a merge, files whose name has no date or an unknown file type, and tags .streamd.toml does not define, each with how many notes use it. Tap a file to open it in the editor at its line, where a banner says what is wrong and turns green once it is fixed; tap a tag to define it in the configuration. Problems never keep you from committing. Below the check is every file you changed, added or deleted since the last commit; tap one to see its diff. The commit message is suggested ("Add 1 note, change 1") and yours to change. If no commit author is set yet, the tab asks for one (name and e-mail) above the message. Two switches of the Repository screen decide how much of this you do by hand:

  • Push right after committing (on): the button is Commit and push. Off, it only commits, and Push appears when there is something to push.
  • Commit every saved note (off): on, every note you save, every task you move and every note you rename or delete is committed by itself (Change 20261012-080000 Monday.md, Rename … to …) and, with the first switch on, pushed — quietly; if a push does not get through, the dot on the tab tells.

If the remote has commits the phone lacks, the push is rejected and the app offers the pull, which merges them. Discard changes, in the menu, puts every file back to the last commit after asking.

Once connected, the Repository screen changes the settings. Another notes folder or another way in is saved; another URL or another branch means another clone, and the app asks before it deletes the one it has, naming the commits that were not pushed and the files that were not committed.

The private key and the token are stored encrypted on the phone, with a key that Android's Keystore holds and never gives out. They and the clone are left out of backups and of a transfer to another phone; the settings are not, so a restored app asks for a new clone with the form filled in — and has a new key.

nix build .#streamd-app     # result/streamd.apk, the debug build (x86_64 Linux)
nix build .#streamd-app-release   # result/streamd-unsigned.apk, the release build before it is signed
nix run .#emulator          # starts an emulator (needs KVM), installs the app and opens it

The emulator is a Pixel 6 with the platform the app is built for; it is kept in ~/.local/state/streamd/android ($XDG_STATE_HOME), so a second start is quick, and runs until its window is closed. What follows -- goes to the emulator (nix run .#emulator -- -no-window).

To work on it with Gradle directly:

nix build .#streamd-android -o android/bindings   # the libraries and the generated Kotlin; again after a change to the crates
nix develop .#android                             # Gradle and the Android SDK
cd android && gradle assembleDebug                # or installDebug, with a device or an emulator connected
gradle testDebugUnitTest                          # the unit tests
gradle validateDebugScreenshotTest                # the screenshot tests
gradle updateDebugScreenshotTest                  # record their references again

The unit tests run on the machine that builds. Those of the session run on the core itself and need its library for that machine: STREAMD_FFI_LIB=$(nix build .#streamd-ffi --no-link --print-out-paths)/lib gradle testDebugUnitTest; without the variable they are skipped. nix build .#streamd-app runs them all.

The screenshot tests render every screen of the app, one file of scenes per screen (android/app/src/screenshotTest/), on the JVM (AGP's Compose Preview Screenshot Testing, no emulator) at 390×844 dp, light and dark, in the baseline colours of Material, and compare them pixel by pixel with the references committed in android/app/src/screenshotTestDebug/reference/. After a change to how a screen looks, record them again, look at the new images and commit them; nix build .#streamd-app fails on a screenshot that differs.

There is no Gradle wrapper: Gradle is the one of the flake. The dependencies of the build are pinned in android/deps.json. Dependency updates, including Renovate PRs, must include the regenerated cache and pass the Android unit and screenshot tests. After a change to a version in android/gradle/libs.versions.toml, to the plugins, or to Gradle through a flake.lock refresh, write it again from the root of the repository and commit it:

nix build .#streamd-app.mitmCache.updateScript && ./result

Vulnerabilities

The dependencies are checked against public advisories, with free and open tools only:

  • The crates: nix flake check runs cargo-audit on Cargo.lock (checks.audit) against the RustSec advisory database, an input of the flake. A vulnerability fails the check; a crate that is only unmaintained is a warning. The database is as recent as flake.lock; nix flake update advisory-db moves it on, and Renovate does so with its lock file maintenance.
  • The Android app: nix run .#scan-android checks what the release build ships of Maven against OSV.dev with osv-scanner, and fails on a known vulnerability but those accepted in osv-scanner.toml, each with its reason and the date until which it is accepted. The list it scans is nix build .#streamd-app-locks (gradle.lockfile of releaseRuntimeClasspath), resolved offline from android/deps.json, so the app keeps no lock file of its own. The scan needs the network, so it is no check of the flake; CI on Forgejo runs it after building the app.
  • Renovate (renovate.json, osvVulnerabilityAlerts) opens pull requests for the dependencies it updates that have a known vulnerability.

Commits & releases

Commit messages must follow Conventional Commits (e.g. feat: add a command, fix(lsp): …). The dev shell installs a commit-msg hook that rejects other messages, and CI checks every new commit. Merge, fixup! and squash! commits are not allowed, and the subject line is limited to 100 characters.

Releases are cut with commitizen by manually starting the Bump Version workflow on main. It bumps the version of the workspace in Cargo.toml (the crates share it) and Cargo.lock, updates CHANGELOG.md, commits bump: version X → Y, and pushes the commit and the vY tag. The tag then starts the Release workflow, which builds and uploads the release artifacts: the .deb package, the static Linux binary, the Windows binary, the Zed extension and the Android app (streamd-<version>-android.apk). The app is the release build (nix build .#streamd-app-release, unsigned), signed in the workflow by nix run .#sign-app with the keystore held in the secrets ANDROID_KEYSTORE (the PKCS12 file, base64) and ANDROID_KEYSTORE_PASSWORD: every release is signed with the same key, so one installs over the one before.

These workflows run on Forgejo (.forgejo/workflows/), which also builds the Android libraries of the bindings and the app (nix build .#streamd-android, .#streamd-app) and scans the dependencies of the app (nix run .#scan-android). A copy of the repository on GitHub runs the checks only (.github/workflows/ci.yml: commit messages, nix flake check, nix build), on the hosted runners; it cuts no releases and does not build for Android.

To preview the next version and changelog from the dev shell:

cz bump --dry-run

Secrets on Forgejo

The workflows read three secrets of the repository (Settings → Actions → Secrets):

Secret What it is Used by
RELEASE_TOKEN An access token that may push to main and create releases (write access to the repository). Bump Version (pushes the commit and the tag, which must start the next workflow) and Release (uploads the artifacts)
ANDROID_KEYSTORE The keystore the Android app is signed with: a PKCS12 file, base64-encoded on one line. Release
ANDROID_KEYSTORE_PASSWORD The password of that keystore and of its key. Release

The keystore is made once, outside of the repository, with one password for the store and the key:

nix shell nixpkgs#jdk -c keytool -genkeypair \
  -keystore streamd-release.p12 -storetype PKCS12 \
  -alias streamd -keyalg RSA -keysize 4096 -validity 10000 \
  -dname "CN=<your name>"
base64 -w0 streamd-release.p12        # the value of ANDROID_KEYSTORE

Keep the file and its password somewhere safe: Forgejo does not show a secret again, and Android installs a release over an earlier one only if both are signed with the same key. Without the two secrets the Release workflow fails when it signs the app, before anything is uploaded.

The next version is derived from the commits since the last tag (fix → patch, feat → minor, breaking changes → minor while still on 0.x). The Nix package reads its version from Cargo.toml.