Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1020,9 +1020,9 @@ Notes:
acknowledgements*, which are derivable, instead of *who is currently blocked in a
wait*, which is not (see *Knowing whether work is moving*).
- `pad search` is the one read that selects by **what was written** rather than by
position, and it is a CLI (and store) capability rather than an MCP tool for now — the
MCP surface stays append-only and small on purpose, and this can be added to it and to
the Web UI later from the same `store.Search`. Four decisions are load-bearing:
position. The CLI and the Web UI both spell it, from the one `store.Search`; it is
deliberately not an MCP tool, because that surface stays append-only and small — and
it can be added there later from the same call. Four decisions are load-bearing:
- **No index.** It reads the pads it looks at. An index would be state living outside
the pad files, and everything here derives from them precisely so that a person with
`rm` or an editor cannot leave a stale second copy of the truth. `--project`, `--pad`
Expand All @@ -1048,6 +1048,21 @@ Notes:
contract here: a lone header reads as one result to anything counting lines
(`… 2>/dev/null | wc -l`), which is how a script concludes "found it" about a word that
is not there. Everything a person needs to read the silence is on stderr.

A hit is **cut around its match, never from the front** (`SearchRequest.TextWidth`), and
carries the match's rune offsets. Agent prose arrives as one long line, so a window taken
from the start of it returned a row that did not contain the word being reported — a
wrong answer wearing the shape of a truncated one. The offsets are published rather than
recomputed per surface for the reason everything else here has one definition: a second
matcher disagrees, `--word` being spelled with Unicode classes that `\b` does not mean.

**The Web UI is the same search asked three ways**, not three features: no scope is the
whole store, `project` is one project, `ref` is one pad — the last being the only form
that carries a password, exactly as in the CLI. Over HTTP the password comes from the
session that already unlocked the pad, so it never travels in a URL; `limit` gains a
ceiling the CLI does not need, because `--limit 0` at a terminal is a person waiting for
their own command while over HTTP it is a request to serialise the store; and the
question lives in the URL, so a search is a link.
- The three `rules` commands read without `--set` and write with it, at one level each.
Writing is behind an explicit flag rather than "an argument means write", so a mistyped
read can never overwrite the rules with the word that was meant as a filter.
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ scratchpad pad search "retry budget" --oldest # where it was DECIDED, not last
Search reads the pads it looks at — there is no index, because an index would be state
living outside the pad files. Narrow with `--project`, `--pad` or `--exclude-pad`.

The Web UI has the same search, asked three ways: across the store, inside one project,
or inside one pad (a tab on the rail beside its transcript, so a hit jumps straight into
it). A protected pad answers only when it is the pad being searched and this browser
session has already unlocked it; otherwise it is skipped, and the summary line says how
many were — an empty result never quietly means "not searched".

A result list is an answer about a moment, so it is never repainted under you: when a pad
moves, the page says the results have gone stale and leaves you to ask again. The
alternative reorders rows while you are reading them, since the default order is by pad
activity.

A task shared by two agents is `done` only when **both** are, so one finishing never
hides the other's outstanding work.

Expand Down Expand Up @@ -264,13 +275,13 @@ reach the UI may change those, so what decides reachability itself (`tcp`, `ui`,
| **Turn rule** | Nobody posts twice in a row *in the conversation* — a clean, readable back-and-forth. Task events are exempt, so dispatching work never blocks on a reply. |
| **Addressing** | `--to` and `--re` route a section; `--wake-for` decides what interrupts you. Reading stays universal. |
| **Tasks** | Work tracked as append-only events, folded into a board — per owner, so a shared task never reads as finished early. |
| **Search** | Find a word across pads — bodies and titles. No index: the pads themselves are the only state. |
| **Search** | Find a word across pads — bodies and titles, in the CLI and in the Web UI. No index: the pads themselves are the only state. |
| **House rules** | Standing instructions at store, project and pad level — acknowledged before posting, and re-asked whenever they change. |
| **Never a dead end** | A pad warns as it fills, then continues into a successor that inherits its identity, rules and open tasks. |
| **Append-only pad** | The pad file is the single source of truth. No external state, no database. |
| **Zero setup** | The default store bootstraps itself on first use. |
| **CLI + MCP** | One binary: work on pad files directly, or serve them as MCP tools. |
| **Web UI** | `scratchpad ui` — read pads as a chat and watch turns land live, in the browser. |
| **Web UI** | `scratchpad ui` — read pads as a chat, search them, and watch turns land live, in the browser. |
| **Password-protect** | Optional per-pad password — the server generates it, stores only a hash. |
| **Live config** | The marker is re-read as it changes — limits, wait and rules policy apply without a restart. |
| **Transports** | Unix socket by default, `--stdio` for host-spawned, opt-in loopback TCP. |
Expand Down
19 changes: 6 additions & 13 deletions cmd/scratchpad/pad.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,10 @@ func newPadReadCmd() *cobra.Command {
// searchTextWidth caps how much of a matching line is printed. Agent prose runs to whole
// paragraphs on one line, and a search is a way to FIND a section, not to read it — the
// ellipsis says the line goes on and `pad read --section` is how to see the rest.
//
// The store takes this window AROUND the match. It used to be applied here, to the front
// of the line, which printed a row that did not contain the word being reported whenever
// a paragraph matched past its 140th character — the common case in a pad.
const searchTextWidth = 140

func newPadSearchCmd() *cobra.Command {
Expand Down Expand Up @@ -499,7 +503,7 @@ func newPadSearchCmd() *cobra.Command {
Query: args[0], Project: project, Ref: ref, Password: password,
ExcludePads: excludePad, Regexp: asRegexp, Word: word, CaseSensitive: matchCase,
Author: author, Kind: kind, Before: beforeTS, After: afterTS,
Oldest: oldest, Limit: limit,
Oldest: oldest, Limit: limit, TextWidth: searchTextWidth,
})
if err != nil {
return err
Expand All @@ -521,8 +525,7 @@ func newPadSearchCmd() *cobra.Command {
if h.InTitle {
where = "title"
}
fmt.Fprintf(w, "%s\t§%d\t%s\t%s\t%s\n", h.Ref, h.Section, where, h.Author,
truncateRunes(h.Text, searchTextWidth))
fmt.Fprintf(w, "%s\t§%d\t%s\t%s\t%s\n", h.Ref, h.Section, where, h.Author, h.Text)
}
if err := w.Flush(); err != nil {
return err
Expand Down Expand Up @@ -592,16 +595,6 @@ func parseWhen(s string) (int64, error) {
return 0, fmt.Errorf("invalid time %q (want a date like 2026-07-01 or an age like 30d)", s)
}

// truncateRunes shortens a line for the table without cutting a multi-byte character in
// half — the searches this exists for are frequently not ASCII.
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}

func newPadWaitCmd() *cobra.Command {
var (
dir dirFlags
Expand Down
3 changes: 3 additions & 0 deletions internal/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,9 @@ ignores case unless you pass `--case-sensitive`. Protected pads are NOT searched
unless you name one with `--pad` and its `--password` — anything left out is
listed on stderr, so an empty result never quietly means "not searched".

A long line is cut AROUND its match, so the word you searched for is always in the
row you get back; a leading `…` means the line started earlier.

**Looking for where something was DECIDED? Pass `--oldest`.** The default order
is newest pad first, which answers "what is being said about this" — and a term
the team is arguing about today will fill every result with restatements while
Expand Down
9 changes: 9 additions & 0 deletions internal/skills/topics/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ reads on from what you found. Matching ignores case unless `--case-sensitive`;
`--word` keeps "budget" from being answered by "budgeting", with boundaries that hold for
non-ASCII words too.

The line in a hit is a WINDOW, cut around the match — agent prose arrives as one long
line, and a `…` at either end means it continues there. What you searched for is always
inside the window; if you need the rest, the section number is right there.

### "Where was this decided?" is a different search

Results are grouped by pad, most recently active first — which answers *what is being
Expand Down Expand Up @@ -211,6 +215,11 @@ prints nothing at all — not even the header — the way `grep` is silent. So
`pad search … 2>/dev/null | wc -l` counts hits and nothing else. The summary, the
truncation notice and the list of pads left out are all on stderr.

A person has the same search in the Web UI (`scratchpad ui`): its own page for the store
and for one project, and a tab on a pad's rail for that pad alone. Same rules, including
which pads it will not read — the protected ones are skipped there too, and only the pad
being searched can be opened, by a session that has already unlocked it.

## Tracking work

A long pad is a poor way to learn where a team stands, so work is tracked separately —
Expand Down
146 changes: 140 additions & 6 deletions internal/store/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"regexp"
"sort"
"strings"
"unicode/utf8"

"golang.org/x/sys/unix"

Expand Down Expand Up @@ -69,6 +70,13 @@ type SearchRequest struct {
// Limit caps the hits RETURNED, not the bytes read: a pad whose scan has begun is
// finished, and only the pads after it are skipped. Result.Truncated says the cap bound.
Limit int

// TextWidth caps a hit's line in RUNES, 0 for the whole line. The cut is taken AROUND
// the match rather than from the start of the line, which is the only cut that works
// on the prose this searches: an agent writes a paragraph as one line, and a word
// matched at character 500 is not in the first 140 of them. Cutting from the front
// therefore produced a result row that did not contain the word it was reporting.
TextWidth int
}

// Hit is one matching line, addressed the way the rest of the tool addresses things: by
Expand All @@ -89,6 +97,17 @@ type Hit struct {
// index a person reads a long pad by, so a search that ignored them would miss the
// most deliberate statement of what a section is about. Line is 0 for these.
InTitle bool `json:"in_title,omitempty"`

// MatchStart and MatchEnd locate the first match INSIDE Text, in runes, half-open.
// Both are -1 when the match could not be located there (see matcher.locate).
//
// They are published rather than left to each caller to recompute because
// recomputing means a SECOND matcher, and a second one disagrees: --word spells its
// boundaries with Unicode classes that \b does not mean, and case folding is on by
// default here and off in most regex dialects. A surface that highlighted its own
// idea of the match would draw a box around the wrong characters.
MatchStart int `json:"match_start"`
MatchEnd int `json:"match_end"`
}

// SearchResult is what a search found, plus what it did NOT look at. Skipped and
Expand All @@ -112,6 +131,14 @@ type SearchResult struct {
type matcher struct {
lit []byte
re *regexp.Regexp

// group says the pattern was wrapped in a capturing group to carry --word's
// boundaries, so what MATCHED is submatch 1 rather than the whole match. The
// boundaries are consuming character classes (RE2 has no lookaround), and they were
// harmless while the result was only ever a boolean — the moment a caller highlights
// the range, reporting them as part of the match paints the space either side of the
// word.
group bool
}

func newMatcher(req SearchRequest) (matcher, error) {
Expand All @@ -126,10 +153,12 @@ func newMatcher(req SearchRequest) (matcher, error) {
if !req.Regexp {
expr = regexp.QuoteMeta(q)
}
group := false
if req.Word {
// No lookaround in RE2, so the boundaries are consuming character classes. That is
// harmless here: the result is only ever used as a boolean.
expr = `(?:^|[^\p{L}\p{N}_])(?:` + expr + `)(?:[^\p{L}\p{N}_]|$)`
// The capturing group opens BEFORE any group the caller's own pattern contains,
// so submatch 1 is always this one — never theirs.
expr = `(?:^|[^\p{L}\p{N}_])((?:` + expr + `))(?:[^\p{L}\p{N}_]|$)`
group = true
}
if !req.CaseSensitive {
expr = `(?i)` + expr
Expand All @@ -138,7 +167,7 @@ func newMatcher(req SearchRequest) (matcher, error) {
if err != nil {
return matcher{}, fmt.Errorf("bad search pattern: %w", err)
}
return matcher{re: re}, nil
return matcher{re: re, group: group}, nil
}

func (m matcher) match(b []byte) bool {
Expand All @@ -148,6 +177,52 @@ func (m matcher) match(b []byte) bool {
return bytes.Contains(b, m.lit)
}

// find returns the byte range of the first match in b. It is kept apart from match
// because match is the hot path — every line of every pad goes through it — while this
// runs only for the lines that already matched.
func (m matcher) find(b []byte) (start, end int, ok bool) {
if m.re == nil {
i := bytes.Index(b, m.lit)
if i < 0 {
return 0, 0, false
}
return i, i + len(m.lit), true
}
loc := m.re.FindSubmatchIndex(b)
if loc == nil {
return 0, 0, false
}
if m.group && len(loc) >= 4 && loc[2] >= 0 {
return loc[2], loc[3], true
}
return loc[0], loc[1], true
}

// locate returns the first match's RUNE range inside s, or (-1, -1).
//
// It runs against the CLEANED text rather than reusing an offset taken from the raw
// line, because cleanLine rewrites the line: a tab becomes four spaces, which moves
// every byte after it. Re-finding on the string the caller will actually display is
// what keeps the two in step. It can legitimately fail — a pattern that matches a tab
// no longer matches the spaces it became — and -1 then means "cannot say", not "no
// match": the hit stands, only the highlight is dropped.
func (m matcher) locate(s string) (int, int) {
bs, be, ok := m.find([]byte(s))
if !ok {
return -1, -1
}
return utf8.RuneCountInString(s[:bs]), utf8.RuneCountInString(s[:be])
}

// excerptOf turns a matching line into what a hit carries: the text to show and where
// the match sits in it. Locating and cutting are one step because they are one decision
// — the cut is taken around the match — and splitting them across the two call sites
// below is how a title hit and a body hit would end up measured differently.
func (m matcher) excerptOf(s string, width int) (string, int, int) {
start, end := m.locate(s)
return excerpt(s, start, end, width)
}

// padHits groups one pad's hits so the result can be ordered by the pad's last activity,
// the way `pad list` orders pads — a search across a store is read newest conversation
// first, not alphabetically.
Expand Down Expand Up @@ -372,9 +447,11 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad
if !selects(sec) || !m.match(line) {
return
}
text, ms, me := m.excerptOf(cleanLine(line), req.TextWidth)
hits = append(hits, Hit{
Ref: ref, Section: sec.N, Author: sec.Author, Title: sec.Title,
Kind: sec.Kind, TS: sec.TS, Line: lineNo, Text: cleanLine(line),
Kind: sec.Kind, TS: sec.TS, Line: lineNo, Text: text,
MatchStart: ms, MatchEnd: me,
})
})
if err != nil {
Expand All @@ -394,9 +471,14 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad
if !selects(&sec) || !m.match([]byte(sec.Title)) {
continue
}
// A title is not cut. It is one short line by rule, and it is the thing a person
// scans a result list by — trimming it to a window would hide the very statement
// that made the section worth returning.
text, ms, me := m.excerptOf(sec.Title, 0)
hits = append(hits, Hit{
Ref: ref, Section: sec.N, Author: sec.Author, Title: sec.Title,
Kind: sec.Kind, TS: sec.TS, Text: sec.Title, InTitle: true,
Kind: sec.Kind, TS: sec.TS, Text: text, InTitle: true,
MatchStart: ms, MatchEnd: me,
})
}
sort.SliceStable(hits, func(i, j int) bool {
Expand All @@ -413,3 +495,55 @@ func (s *Store) searchPad(project, id string, req SearchRequest, m matcher) (pad
func cleanLine(line []byte) string {
return strings.TrimRight(strings.ReplaceAll(string(line), "\t", " "), "\r")
}

// ellipsis marks an excerpt that starts or ends mid-line. One rune, so the offsets it
// shifts move by exactly one.
const ellipsis = "…"

// excerpt cuts s down to width runes AROUND the match at [start, end), returning the
// text and the match's range within it.
//
// Centring on the match is the whole point: an agent's paragraph arrives as a single
// line, and the answer to "where was this said" is usually in the middle of it. A cut
// taken from the front returns a row that does not contain the word being reported,
// which reads as a wrong result rather than as a truncated one.
//
// A match that is itself wider than the window is shown from its start — cutting it in
// half would be the same failure one level down. start < 0 means the match could not be
// located, and the line is then cut from the front, since there is no better place.
func excerpt(s string, start, end, width int) (string, int, int) {
if width <= 0 {
return s, start, end
}
r := []rune(s)
if len(r) <= width {
return s, start, end
}
if start < 0 {
return string(r[:width]) + ellipsis, -1, -1
}

// Centre the window on the match, then push it back inside the line. The clamp order
// matters: pinning to the end first and to the start second keeps `from` at 0 for a
// line shorter than the window, which the length check above has already excluded but
// which a later change could reintroduce.
from := start - max(0, (width-(end-start))/2)
from = min(from, len(r)-width)
from = max(from, 0)
to := min(from+width, len(r))

out := string(r[from:to])
ns, ne := start-from, min(end, to)-from
if from > 0 {
out = ellipsis + out
ns++
ne++
}
if to < len(r) {
out += ellipsis
}
// A match running past the window is reported only as far as the text goes; the
// range must never point outside the string a caller is about to highlight.
ne = min(ne, len([]rune(out)))
return out, ns, max(ns, ne)
}
Loading