nodwyn.

Session memory

The lossless session log remains the source of truth. The window, summary selection and structural retrieval are derived views. Memory never restores credential permissions, approvals or Scheme state.

Exact request replay

For logs recorded since v0.27, find an llm.req sequence in the transcript/log and run:

nodwyn replay SESSION --window REQUEST_SEQ

The command rebuilds the provider-neutral request from its preceding log prefix and recorded runtime recipe, then verifies its digest before printing JSON. It does not load current tool definitions, call the model or run tools. Legacy logs without the recipe/hash still support ordinary transcript replay, but cannot establish exact request reconstruction. Unknown recipes and mismatches are errors, not guessed output. The digest is not an authenticated log seal.

Reviewable, revocable summaries

This first summary slice accepts user-authored text. It does not generate text with a model or rewrite the source conversation. Close the session before editing; an attached or detached daemon session still owns its log until explicitly closed. The CLI refuses to race an active owner. Incomplete logs must be recovered by the normal resume path before summary editing.

Select a span beginning at a user.msg event and ending at a completed llm.resp without pending tool calls. Put its reviewed summary in a UTF-8 file (maximum 16 KiB). For example:

nodwyn summary --from 12 --through 41 --text summary.txt SESSION
nodwyn summary SESSION
nodwyn summary --drop SUMMARY_SEQ SESSION

Flags precede the session ID. Creation prints the new summary event sequence; use that sequence with --drop. Inspection lists every summary and its status: active, recent, dropped, stale, or superseded. Text passes the same mandatory redaction boundary as other log content. Operations append summary or summary.drop; neither rewrites original events or creates another database. Transcript replay and the TUI show those records without reapplying an edit.

The window replaces an eligible older span with one assistant message labeled as a revocable summary, never with a system instruction. The latest two user turns are excluded from summary replacement. Existing token-budget elision and large-result caps still apply; this is not a claim that unlimited recent tool output is sent verbatim.

A summary carries a digest of its redacted source conversation. Missing or changed source invalidates it and leaves original conversation events available. A newer overlapping summary supersedes an older one; revoking or invalidating the newer cache does not revive the older cache. Summary edits reset derived sticky-elision decisions, then the budget policy recomputes from source. This makes revocation effective rather than leaving an old cached omission in place.

Recipes from window-v2 on record this policy (new sessions record the current one, window.RecipeVersion, whose comment in internal/window/replay.go says what each version added); historical window-v1 requests retain their original policy. Replaying an earlier request uses only the log prefix before that request, so a later revocation cannot change what the model originally saw. The checked-in v0.27 selfcheck fixture exercises real v1 compatibility; synthetic and fake-service tests separately exercise replacement, revocation and canaries.

Repository text retrieval

index-grep is a read-only Scheme primitive and model tool:

(index-grep "internal/window" "func (Build|ReplayRequest)")
(index-grep "README.md" "summary")
(index-grep "." "TODO" "*.go" 2)    ; optional glob, and up to 5 lines of context

It returns an object/alist with matches (path, one-based line, text, and with context the lines before and after), files searched, skipped candidates and truncated. Results are sorted by path and line. An empty match list is an ordinary no-match result. A truncated result is explicitly incomplete: narrow the path or pattern. There is no persistent index; every call observes current files under that session's repository root.

fs-find lists file names the same way, without reading them.

Search runs entirely in Go and needs no program on PATH (before 2026-09-30 it needed ripgrep, rg, which an app on a machine without developer tools lacked). Discovery walks the directory in path order and lists regular files only: it skips hidden entries below the path searched (a name starting with a dot, .git included) and symbolic links, follows no link, and loads no ignore file, since an ignore file could itself link to protected content. Non-hidden ignored files are therefore candidates; select a narrower directory, or pass a glob, to leave out build and vendor output. An explicit file path may name a hidden file, subject to the same guard.

A glob follows ripgrep's --glob: *, ? and [...] match within a path component, ** spans directories and {a,b} is either alternative; a glob without a slash matches any component (*.py at any depth, tests for every file under a tests directory); one with a slash matches from the start of the path, relative to the directory searched or to the repository root, and matching a directory matches the files under it; a leading ! keeps every path the rest does not match.

Nonregular, protected, oversized, binary and non-UTF-8 candidates are skipped; the skipped count excludes paths discovery never listed (hidden files, for example). Each selected read goes through fs-read and the full shared guard, including extension checkers. A denial stops the search; it is never treated as an empty file. Results carry file provenance and pass ordinary tool-result redaction before logging or entering a model request.

A pattern is a Go regular expression (RE2, the syntax ripgrep also uses), at most 4 KiB; it is never a command-line option. Limits are 3000 candidate files (each read's guard decision is logged, so this also bounds the log), 100 matching lines, the per-file read and output caps, and 30 seconds for the whole query; more candidates is an error asking for a narrower path or a glob, never a partial list. Cancellation stops the walk. File edits concurrent with a query have ordinary filesystem read semantics; this is not a transactional snapshot.

Source outlines (removed)

index-symbols, a Tree-sitter outline of one Go, JavaScript or Python file, was removed on 2026-09-27. Models did not use it (no call in 85 local sessions, against hundreds of fs-read and shell-exec calls), and it carried about 1.1 MB of vendored grammar binaries plus a WebAssembly runtime: the binding's Go module embeds only JavaScript and JSON, so Go and Python could not come from it. index-grep and fs-read cover retrieval.

Compaction

Within a session, long work is compacted: just under the window's high water, or when you send /compact, the model writes a handover note and the conversation goes on from it, in that turn and later ones (the manual, "Changing the loop"; docs/scheme.md §9.2). The note is model-written and narrows the window (context.narrow); it is not a summary record, and the log keeps the whole conversation.

Conversation retrieval

What a model sees of a long conversation shrinks: the window elides old replies and results ([elided seq=N]), strategies fold results to an outline, shell output keeps its first 1,500 and last 6,000 characters, and a compaction leaves a note. Two read-only primitives, offered as tools, read the rest back from the log (ADR 50):

(index-search "go test core failure" "conversation")   ; the best 8 events
(index-search "ScrubEnv" "conversation" 20)            ; up to 20
(recall 812)                                          ; seq 812, whole
(recall 812 200 50)                                   ; its lines 200 to 249

index-search ranks the events a model could have seen: the person's and nodwyn's messages, attached files, plans, summaries, replies with their tool calls, and tool results with the call each answers, in sub-agents' contexts too, each named by its seq. Not guard, egress, request or note records, not thinking, and not earlier searches and recalls, whose results repeat other events (a search would otherwise find its own call). Words are split as code names them, so ScrubEnv matches scrub and index-grep matches grep, and events are ranked by BM25: more of the query's words, and rarer ones, rank first, and a result's call counts three times its output. Events found are shown under the turn that holds each, the turn in a line from its card (how it ended, how long it took, what it cost, what it asked; below), and each event with its three lines that hold the most of the words, numbered as recall numbers them. The repository's scope and earlier tasks' are below.

recall returns one of those events as the log recorded it, already redacted, under a heading naming it (seq 812, the result of shell-exec {…}, which failed, in the explorer's context), in lines as a ranged fs-read returns a file: [lines A-B of N], stopping at a whole line within the window's cut for one result. A tool's result is recorded as the tool returned it, and a shell's or a search's is one line of JSON, so both primitives read a result as text with lines: a shell's as the model read it (exit N, the output, then [stderr] and the errors, as shell.scm renders it) but whole, other JSON indented. A reply names the seq of each of its calls' results, and a turn's first message comes with what the turn did and the seq of every event in it. It shows what was then: a file as it is now is fs-read's.

The words are the log's memory, <id>.mem beside it (below): kept to the end of its last whole turn and brought up to date from where they end at each search, the events after them folded at the search, so a resumed task reads only what its log gained, never the whole log again. A search reads only its own words' postings and keeps nothing after; a log that is not a file is folded from the events the session holds. Both values carry external:log provenance, as session-events's do.

The repository index

index-grep reads every candidate file on every call and keeps nothing. index-search with scope "repo" ranks chunks of the repository's files from an index nodwyn keeps (ADR 50):

(index-search "credential binding check egress" "repo")        ; the best 8
(index-search "deploy" "repo" 20 "docs")                       ; only docs/
(index-update)                                                ; build it all now
(index-update "third_party")                                  ; add an ignored directory

Which files: those git lists, tracked or untracked and not ignored (in .nodwyn/ only tracked ones, so other sessions' worktrees and attachments never enter); without git, the walk index-grep does. index-update path adds a directory or file of the repository whole, git's ignores notwithstanding, from then on. A repository's .gitignore is written by whoever commits to it, a stranger's clone included; it can only narrow what is indexed, since every file enters through the guard. At most 20,000 files and 128 MiB of text a tree; a file over 1 MiB, binary or not UTF-8 is skipped, and past the limits a search says the index holds only part.

How a file enters: through fs-read, so the pin, the denylist, a project's register-check checkers and the turn's limits apply, and each read logs its guard.decision. A read the guard would ask about is not asked (guard.WithoutAsking): the file is left out and named, as one it refuses is. The text is redacted as the log redacts a tool result, then cut into chunks: a chunk starts at a line beginning a declaration or a heading in its first column, once the chunk before has 20 lines, and has at most 80. What is stored is each chunk's line numbers and its words with their counts, never text.

Where: projects/<key>/index in nodwyn's data directory, beside the project's notes, so the model's file tools cannot reach it and a clone cannot write it. A file's chunks are stored once under the hash of its redacted text, so the worktrees and branches of a repository share them; a manifest per working tree records each file's size, modification time, inode and hash, updated under a lock and renamed into place. All of it is derived: deleting the directory loses nothing.

Fresh: every search first reads again, for at most 10 seconds, the files whose size, time or inode changed, and drops the ones gone; the first search builds the index (nodwyn's 712 files took 1.3 seconds, and a search about 90 milliseconds, on the computer this was written on). index-update reads for up to 60 seconds a call and goes on at the next, removes the manifests of working trees that are gone and chunks no tree has named for seven days. A build or refresh that read or removed a file records an index.update (an index-update call always does): files, read, removed, skipped, refused, left to read; never content.

What a search shows is read from the file again, through fs-read and the whole guard: path:A-B: first line, then the chunk's three lines holding the most of the words, numbered as in the file. A refusal there is the answer, and drops the file from the index. Ranking is BM25 over the words, with the path and a chunk's first line counted three times. There are no embeddings: they wait for a model to make them (ADR 50, the person's answer 1).

Earlier tasks

With the person's leave for a project, index-search with scope "earlier" ranks the same kinds of event in the project's other tasks, and recall with a task's id brings one back (ADR 50 §9):

(index-search "why did the release fail" "earlier")
(recall 812 0 0 "20261009T034803Z-3981612f")

The leave is earlier_tasks on the project's entry in workspaces.toml, which only the console and the TUI on this computer write (the folder's editor on Workspaces, :earlier on) and no model or repository can reach; it is read at every call, so turning it on or off counts at once, and off is the default. Which tasks: those whose project (SessionInfo.Project()) is this session's, so a worktree's tasks are its repository's; archived tasks are included and marked; a deleted task's log is gone and the task with it. The events of all of them are ranked as one collection, a word weighing by how many events of all the tasks hold it, a tie going to the newer task. Each turn found names its task (id, name or first message, the day of its last event, whether it is archived), and each event its three best lines, read from its log again by its seq.

Each log's words are its <id>.mem, as for the conversation. On the computer this was written on, the first search of nodwyn's 73 tasks (17,616 events) wrote their files, 10 MB in all, in 1.3 seconds, read in parallel; a search after that, in any process, took about 20 milliseconds, and about 1 MB was left in memory after it. (Kept in memory per process instead, as first built, the same words took 3.6 seconds and 87 MB, for as long as the process ran.)

Task memory

A step up from events, each task keeps a card for every turn and the note of every compaction (ADR 51): what was asked, how the turn ended, how long it took and what it cost, its plan with the marks made, the files written, the commands run and how they ended, errors, and the start of the answer. The cards are made in Go from the log, so they cost nothing. They are what index-search shows each event under, and what recall adds to a turn's first message:

(index-search "flaky pager test" "conversation")    ; events, under their turns
(recall 158)                                        ; a turn's message, with its card

<id>.mem beside each log holds the cards, the notes and the words of the events above, to the end of the last whole turn, with where that turn's line ends and its hash; a read checks that line, folds in only what the log gained and writes the file again, whole, as a temporary file renamed into place. One that does not match is made again from the log, and deleting it is always safe. The words are a list of each event's seq and what it is, then each word with the events that hold it and how often, so a search reads the postings of its own words and skips the rest. The person searches every task of a repository from a terminal with nodwyn memory search and nodwyn memory recall, whatever the switch for earlier tasks says.

Across sessions, a repository keeps its project notes (project-notes, docs/scheme.md), its index (above), its tasks' memory (above) and, when the person allows it, the model searches its earlier tasks (above); there is no embedding index. That is not a claim made by this P10 implementation.