Add Notes

Friday can do research. It can synthesise findings, draft talking points, analyse a codebase overnight. And then the session ends and all of it vanishes.

The problem isn't that Friday lacks persistence. HUD entries, scheduled tasks, and background tasks all track what to do. None of them store what was found. If you want Friday to prepare for a meeting, it has to happen in one shot – spread it across two days and the first day's work is gone.

Notes as Persistent Context

We could tack a body field onto HUD entries, or stash findings inside background task rows. But both of those stores already have a job. Notes need their own table – a separate store for things that need to be remembered.

The lifecycles are different. A HUD entry for "Prep: Q2 board meeting" should disappear when the meeting is over. The research notes attached to it might be useful for months. Overloading one table with both concerns would muddy the purpose of each.

An optional hud_entry_id foreign key can link a note to a HUD entry, so one entry can accumulate multiple notes over time. Or a note can stand alone – not everything needs a parent.

The schema is minimal:

CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    hud_entry_id INTEGER,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    deleted_at TEXT
);

CREATE INDEX IF NOT EXISTS idx_notes_hud_entry_id
    ON notes (hud_entry_id);

CREATE INDEX IF NOT EXISTS idx_notes_deleted_at
    ON notes (deleted_at);

This is from src/data/tables/notes.ts

Title, body, optional HUD link, timestamps, soft-delete. Two indexes – one for filtering by HUD entry, one for excluding deleted rows efficiently.

The schema definition doesn't end there, though. The same file includes a virtual table and three triggers that handle full-text search.

FTS5 Under the Hood

SQLite's FTS5 extension can operate in two modes. Standalone mode stores its own copy of the text – simple, but it means every note's title and body exist twice. Content table mode stores only the index and references the source table for actual data. We use content table mode:

CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
    title,
    body,
    content='notes',
    content_rowid='id'
);

This is from src/data/tables/notes.ts

The content='notes' parameter tells FTS5 to look up actual text from the notes table when it needs to return results. The content_rowid='id' maps FTS5's internal rowid to our primary key. The tradeoff is that FTS5 can't keep itself in sync automatically – we need triggers for that.

Three triggers handle the sync:

CREATE TRIGGER IF NOT EXISTS notes_fts_ai AFTER INSERT ON notes BEGIN
    INSERT INTO notes_fts(rowid, title, body)
    VALUES (new.id, new.title, new.body);
END;

CREATE TRIGGER IF NOT EXISTS notes_fts_ad AFTER DELETE ON notes BEGIN
    INSERT INTO notes_fts(notes_fts, rowid, title, body)
    VALUES ('delete', old.id, old.title, old.body);
END;

CREATE TRIGGER IF NOT EXISTS notes_fts_au AFTER UPDATE ON notes BEGIN
    INSERT INTO notes_fts(notes_fts, rowid, title, body)
    VALUES ('delete', old.id, old.title, old.body);
    INSERT INTO notes_fts(rowid, title, body)
    VALUES (new.id, new.title, new.body);
END;

This is from src/data/tables/notes.ts

The insert trigger is straightforward. The other two look wrong until you've read the FTS5 docs – that INSERT INTO notes_fts(notes_fts, ...) with the literal string 'delete' isn't inserting a row. It's a special FTS5 command that removes an index entry by rowid. The update trigger fires the same delete command and then re-inserts, because FTS5 doesn't support in-place updates.

All of this lives in a single createTable string that runs when Friday starts. If any part is missing, search breaks silently.

The Soft-Delete Gotcha

Deleting a note sets deleted_at rather than removing the row – same pattern as HUD entries. But soft-delete and FTS5 don't play well together.

The row stays in notes, so the AFTER DELETE trigger never fires. The FTS index still contains the text. A raw query against notes_fts would happily return soft-deleted notes.

Every search path has to compensate by joining back to the source table:

export function search(db: Database.Database) {
    return (query: string): NoteSearchResult[] => {
        const safe = sanitiseQuery(query);
        try {
            return db
                .prepare(
                    `SELECT notes.*, notes_fts.rank
                     FROM notes_fts
                     JOIN notes ON notes.id = notes_fts.rowid
                     WHERE notes_fts MATCH ?
                     AND notes.deleted_at IS NULL
                     ORDER BY notes_fts.rank`
                )
                .all(safe) as NoteSearchResult[];
        } catch {
            return [];
        }
    };
}

This is from src/data/stores/notes/search.ts

The join pulls actual data from the source table – content table mode requires that anyway. The AND notes.deleted_at IS NULL clause is the one-line fix. Without it, deleted notes keep surfacing.

sanitiseQuery wraps plain queries in double quotes so they're treated as phrases. If the query already contains FTS5 operators like AND, NOT, or NEAR, we pass it through as-is. Without the wrapping, board meeting would be two separate terms with an implicit AND.

Hard-delete sidesteps all of this – the row is removed, the trigger fires, the index cleans itself. But it's irreversible. We trade a WHERE clause for the ability to undo mistakes.

The Store Refactor

The evening before notes shipped, I looked at the existing stores and didn't like what I saw.

Each store was a single file – background-tasks.ts at 191 lines, scheduled-tasks.ts at 172, hud-entries.ts at 133. Every method lived in the same class, every type was defined at the top, and the test files mirrored the same monolithic shape. It worked, but adding a new store meant copying that pattern and ending up with another long file that would only grow.

This wasn't planned work. No feature branch, no feature doc – just an evening of cleanup straight to main.

The split moves each method into its own file under src/data/stores/<store>/. A create.ts, a get.ts, a list.ts – each exporting a factory function that takes the database and returns the operation. The store class becomes a thin wrapper that wires the factories together. Types move to a types.ts in the same directory.

The test files follow the same structure – one test file per method. More files, but each one fits on a single screen.

Notes happened to be the next feature, so it became the first store built in the new pattern from scratch.

Store and API Patterns

The notes store adds one thing the others don't have: a body length limit.

const BODY_LIMIT = 100_000;

export function create(db: Database.Database) {
    return (fields: {
        title: string;
        body: string;
        hud_entry_id?: number;
    }): Note => {
        if (fields.body.length > BODY_LIMIT) {
            throw new Error(
                'Note body exceeds 100,000 character limit'
            );
        }
        const now = new Date().toISOString();
        const result = db
            .prepare(
                'INSERT INTO notes (title, body, hud_entry_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
            )
            .run(
                fields.title,
                fields.body,
                fields.hud_entry_id ?? null,
                now,
                now
            );
        return get(db)(result.lastInsertRowid as number)!;
    };
}

This is from src/data/stores/notes/create.ts

SQLite's TEXT type has no practical size cap. The 100k limit – roughly 20,000 words – is an application decision, enforced in the store so it applies regardless of how notes are created.

The HTTP layer is six endpoints under /notes – standard CRUD plus a search route. One routing detail caught me out: /notes/search has to be registered before /notes/:id in Hono, otherwise "search" gets captured as the :id parameter.

Claude interacts with notes through three skills: custom-notes-manage for create, update, and delete; custom-notes-list for browsing; custom-notes-search for full-text queries. Same pattern as HUD and background task commands – one skill per concern.

Conclusion

Friday can save what it finds. A background task researches meeting attendees, stores the results as a note, and tomorrow's session picks up where it left off. FTS5 handles ranking, so "meeting" matches "meetings" and results come back in relevance order.

Notes changed how I work with agents. Before, every feature had to fit inside one session – research the problem, design the solution, build it, all in one go. Now I can run research as a background task, read the notes later, and decide whether to start building or shelve the idea. The research tokens aren't wasted either way.

This book exists because of notes. Every chapter starts as a note – application context transferred out of the Friday codebase and into a structured outline that a writing session can pick up cold. The agent doesn't need to re-read the source code or re-discover the design decisions. It's all already written down.

Features split up more naturally too. Research in one session, design in another, implementation in a third. Each session reads what the last one found and builds on it. Nothing is lost between sessions, and nothing is isolated to a single codebase.