From 0342462bb85e886bb92a074a10cdbe66e3c3520d Mon Sep 17 00:00:00 2001 From: krsnaa Date: Fri, 8 May 2026 12:53:15 -0700 Subject: [PATCH 01/67] feat: detailed ingest output with skip reasons, progress bar, and totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the three-line summary with a richer report: - Per-source file counts during the scan phase - Live progress bar (TTY-only, 100ms throttle, sub-char eighth-block fill) - Splits skipped into already_ingested / empty / duplicate_id so the number is actionable instead of opaque - Total messages ingested + elapsed time Before: Scanning 2 source(s)... Found 4650 session file(s) Ingested: 4313, Skipped: 337, Errors: 0 After: Scanning 2 source(s)... 4,294 in /Users/me/.claude/projects 356 in /Users/me/.codex/sessions Found 4,650 session file(s) [████████████████████] 100% · 4,650/4,650 Ingested 4,313 session(s) (812,540 messages) in 47.2s Skipped 337: 337 empty --- src/index.ts | 47 +++++++++++++++++++++++++++++++++++++++++++---- src/ingest.ts | 43 ++++++++++++++++++++++++------------------- 2 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/index.ts b/src/index.ts index f917903..be7fdf2 100755 --- a/src/index.ts +++ b/src/index.ts @@ -37,15 +37,54 @@ switch (command) { } } + const startMs = Date.now(); console.log(`Scanning ${sources.length} source(s)...`); - const files = scanSources(sources, config.exclude); - console.log(`Found ${files.length} session file(s)`); + const files: string[] = []; + for (const s of sources) { + const found = scanSources([s], config.exclude); + files.push(...found); + console.log(` ${found.length.toLocaleString()} in ${s}`); + } + console.log(`Found ${files.length.toLocaleString()} session file(s)`); + + const isTty = process.stderr.isTTY; + const barWidth = Math.max(20, Math.min(40, (process.stdout.columns ?? 80) - 30)); + const renderBar = (done: number, total: number): string => { + const partials = "▏▎▍▌▋▊▉"; + const ratio = total > 0 ? Math.min(1, done / total) : 1; + const eighths = Math.round(ratio * barWidth * 8); + const full = Math.floor(eighths / 8); + const partial = eighths % 8; + const partialChar = partial > 0 && full < barWidth ? partials[partial - 1]! : ""; + const empty = Math.max(0, barWidth - full - (partialChar ? 1 : 0)); + return "█".repeat(full) + partialChar + " ".repeat(empty); + }; + let lastTickMs = 0; + const result = ingestSessions(files, db, force, (done, total) => { + if (!isTty) return; + const now = Date.now(); + if (now - lastTickMs < 100 && done < total) return; + lastTickMs = now; + const pct = total > 0 ? Math.floor((done / total) * 100) : 100; + process.stderr.write( + `\r\x1b[2K [${renderBar(done, total)}] ${pct.toString().padStart(3)}% · ${done.toLocaleString()}/${total.toLocaleString()}` + ); + }); + if (isTty) process.stderr.write("\r\x1b[2K"); - const result = ingestSessions(files, db, force); + const elapsed = ((Date.now() - startMs) / 1000).toFixed(1); console.log( - `Ingested: ${result.ingested}, Skipped: ${result.skipped}, Errors: ${result.errors.length}` + `Ingested ${result.ingested.toLocaleString()} session(s) (${result.totalMessages.toLocaleString()} messages) in ${elapsed}s` ); + if (result.skipped > 0) { + const parts: string[] = []; + if (result.alreadyIngested) parts.push(`${result.alreadyIngested.toLocaleString()} already ingested`); + if (result.empty) parts.push(`${result.empty.toLocaleString()} empty`); + if (result.duplicateId) parts.push(`${result.duplicateId.toLocaleString()} duplicate id`); + console.log(`Skipped ${result.skipped.toLocaleString()}: ${parts.join(", ")}`); + } if (result.errors.length > 0) { + console.log(`Errors: ${result.errors.length}`); for (const err of result.errors.slice(0, 10)) { console.error(` ${err}`); } diff --git a/src/ingest.ts b/src/ingest.ts index 0a310d3..bbe5c76 100644 --- a/src/ingest.ts +++ b/src/ingest.ts @@ -60,10 +60,13 @@ export function scanSources( export function ingestSessions( files: string[], db: Database, - force = false -): { ingested: number; skipped: number; errors: string[] } { - let ingested = 0; - let skipped = 0; + force = false, + onProgress?: (done: number, total: number) => void +): { + ingested: number; skipped: number; errors: string[]; + alreadyIngested: number; empty: number; duplicateId: number; totalMessages: number; +} { + let ingested = 0, alreadyIngested = 0, empty = 0, duplicateId = 0, totalMessages = 0; const errors: string[] = []; const checkStmt = db.query("SELECT id FROM sessions WHERE source_path = ?"); @@ -84,30 +87,27 @@ export function ingestSessions( const deleteConvo = db.prepare(`DELETE FROM conversations WHERE session_id = ?`); const deleteSession = db.prepare(`DELETE FROM sessions WHERE id = ?`); - for (const file of files) { - if (!force) { - const existing = checkStmt.get(file); - if (existing) { - skipped++; - continue; - } + for (let i = 0; i < files.length; i++) { + onProgress?.(i, files.length); + const file = files[i]!; + + if (!force && checkStmt.get(file)) { + alreadyIngested++; + continue; } try { const session = parseSession(file); if (session.messageCount === 0) { - skipped++; + empty++; continue; } // Skip if session ID already exists (e.g., same session in multiple project dirs) - if (!force) { - const existingById = checkSessionId.get(session.sessionId); - if (existingById) { - skipped++; - continue; - } + if (!force && checkSessionId.get(session.sessionId)) { + duplicateId++; + continue; } const projectId = session.projectName; @@ -140,10 +140,12 @@ export function ingestSessions( })(); ingested++; + totalMessages += session.messageCount; } catch (err) { errors.push(`${file}: ${err}`); } } + onProgress?.(files.length, files.length); // Update project aggregate fields db.exec(` @@ -153,5 +155,8 @@ export function ingestSessions( session_count = (SELECT COUNT(*) FROM sessions WHERE sessions.project_id = projects.id) `); - return { ingested, skipped, errors }; + return { + ingested, skipped: alreadyIngested + empty + duplicateId, errors, + alreadyIngested, empty, duplicateId, totalMessages, + }; } From 94e167f78888095bbceeec93fdd80de9074a2223 Mon Sep 17 00:00:00 2001 From: Caroline Sieger Date: Wed, 24 Jun 2026 16:42:11 -0400 Subject: [PATCH 02/67] feat: add Cursor session support (#17) Ingest Cursor session transcripts. Cursor records are {role, message} with no `type` field, and content blocks share Claude Code's {type:"text", text} shape, so the existing text extractors are reused. Metadata absent from Cursor transcripts is derived after parsing: - session id from the filename UUID - start/end timestamps from file birthtime/mtime (Cursor stores none) - project name from Cursor's encoded directory string (used verbatim) `~/.cursor/projects` is not added to default sources; it stays opt-in and is documented in the README along with the format's caveats. Adds parser tests, an ingest regression test, fixtures, and docs. Co-Authored-By: Claude Opus 4.8 --- README.md | 35 ++++++++++- src/ingest.test.ts | 30 +++++++++ src/parser.test.ts | 54 ++++++++++++++++ src/parser.ts | 61 ++++++++++++++++++- ...22222222-2222-4222-8222-222222222222.jsonl | 1 + ...11111111-1111-4111-8111-111111111111.jsonl | 3 + 6 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/cursor/1700000000000/agent-transcripts/22222222-2222-4222-8222-222222222222/22222222-2222-4222-8222-222222222222.jsonl create mode 100644 tests/fixtures/cursor/Users-test-GitRepos-demo-app/agent-transcripts/11111111-1111-4111-8111-111111111111/11111111-1111-4111-8111-111111111111.jsonl diff --git a/README.md b/README.md index 5f190f2..234da12 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Engineering Notebook -A CLI tool that ingests [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and [Codex](https://openai.com/index/introducing-codex/) session transcripts, generates LLM-powered daily summaries, and serves a web UI for browsing your engineering journal. +A CLI tool that ingests [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://openai.com/index/introducing-codex/), and [Cursor](https://cursor.com) session transcripts, generates LLM-powered daily summaries, and serves a web UI for browsing your engineering journal. Think of it as an automatic engineering diary — it watches your AI coding sessions and distills them into a searchable, browsable narrative of what you built, what problems you hit, and what decisions you made. @@ -8,7 +8,7 @@ Think of it as an automatic engineering diary — it watches your AI coding sess ## How It Works -1. **Ingest** — Scans directories of Claude Code and Codex JSONL session files, parses out the human-readable conversation (stripping tool calls, thinking blocks, etc.), and stores them in SQLite. +1. **Ingest** — Scans directories of Claude Code, Codex, and Cursor JSONL session files, parses out the human-readable conversation (stripping tool calls, thinking blocks, etc.), and stores them in SQLite. 2. **Summarize** — Groups sessions by date and project, then uses Claude to write concise engineering journal entries with headlines, summaries, topics, and open questions. 3. **Serve** — Runs a web server with a browsable UI: daily journal, project timelines, calendar/Gantt view, session transcripts, full-text search, and an iCal feed. @@ -142,6 +142,37 @@ webcal://localhost:3000/api/calendar.ics This creates calendar events for each journal entry, viewable in Apple Calendar, Google Calendar, Outlook, etc. +## Cursor support + +Cursor sessions live under `~/.cursor/projects` (Cursor's `agent-transcripts/` +layout). This source is **not** scanned by default — add it explicitly: + +```sh +engineering-notebook ingest --source ~/.cursor/projects +``` + +Or add `~/.cursor/projects` to the `sources` array in your config. + +Cursor's transcript format is leaner than Claude Code's or Codex's, so a few +caveats apply: + +- **Timestamps come from file modification times.** Cursor transcripts contain no + per-message timestamps, so a session's start and end are taken from the file's + creation and last-modified times, and per-message times are approximate. Copying + or restoring transcript files can reset these. +- **Project names are the raw encoded directory string** (for example + `Users-username-GitRepos-my-repo`). Cursor does not record the working directory, + and its directory names encode the path lossily — both `/` and `.` collapse to + `-` — so the name is shown verbatim rather than guessed at. +- **Cursor sessions do not auto-merge** with the same repository's Claude Code or + Codex sessions, which group by the real working-directory name. +- **Some Cursor projects appear under an opaque numeric id** (for example + `1700000000000`) when Cursor stored no recoverable path. + +Planned improvements (not yet implemented): stripping `` and +terminal-selection wrappers from Cursor messages, and recovering real project +names via Cursor's `workspaceStorage` mapping. + ## Development ```sh diff --git a/src/ingest.test.ts b/src/ingest.test.ts index c4f6ee9..9dba37b 100644 --- a/src/ingest.test.ts +++ b/src/ingest.test.ts @@ -170,4 +170,34 @@ describe("ingestSessions", () => { expect(session?.version).toBe("0.99.0-alpha.23"); expect(session?.message_count).toBe(2); }); + + test("ingests a Cursor session file into the database", () => { + const uuid = "11111111-1111-4111-8111-111111111111"; + const fixturePath = join( + import.meta.dir, + `../tests/fixtures/cursor/Users-test-GitRepos-demo-app/agent-transcripts/${uuid}/${uuid}.jsonl` + ); + const dir = join(tempDir, "Users-test-GitRepos-demo-app", "agent-transcripts", uuid); + mkdirSync(dir, { recursive: true }); + const sessionFile = join(dir, `${uuid}.jsonl`); + copyFileSync(fixturePath, sessionFile); + + const result = ingestSessions([sessionFile], db); + expect(result.ingested).toBe(1); + expect(result.skipped).toBe(0); + expect(result.errors.length).toBe(0); + + const session = db + .query("SELECT id, project_id, message_count, is_subagent FROM sessions") + .get() as { + id: string; + project_id: string; + message_count: number; + is_subagent: number; + } | null; + expect(session?.id).toBe(uuid); + expect(session?.project_id).toBe("Users-test-GitRepos-demo-app"); + expect(session?.message_count).toBe(3); + expect(session?.is_subagent).toBe(0); + }); }); diff --git a/src/parser.test.ts b/src/parser.test.ts index ec0907c..f161f60 100644 --- a/src/parser.test.ts +++ b/src/parser.test.ts @@ -6,6 +6,14 @@ const fixturePath = join(import.meta.dir, "../tests/fixtures/test-session-1.json const codexFixturePath = join(import.meta.dir, "../tests/fixtures/test-codex-session-1.jsonl"); const subagentFixturePath = join(import.meta.dir, "../tests/fixtures/parent-session-id/subagents/agent-aba4e4e.jsonl"); const commandFixturePath = join(import.meta.dir, "../tests/fixtures/test-command-messages.jsonl"); +const cursorFixturePath = join( + import.meta.dir, + "../tests/fixtures/cursor/Users-test-GitRepos-demo-app/agent-transcripts/11111111-1111-4111-8111-111111111111/11111111-1111-4111-8111-111111111111.jsonl" +); +const cursorEpochFixturePath = join( + import.meta.dir, + "../tests/fixtures/cursor/1700000000000/agent-transcripts/22222222-2222-4222-8222-222222222222/22222222-2222-4222-8222-222222222222.jsonl" +); describe("parseSession", () => { test("extracts session metadata", () => { @@ -126,4 +134,50 @@ describe("parseSession", () => { expect(userMessages[0]!.text).toBe("/brainstorm fix the login bug"); expect(userMessages[1]!.text).toBe("/commit"); }); + + test("parses Cursor records (role + message, no type)", () => { + const session = parseSession(cursorFixturePath); + expect(session.messages.length).toBe(3); + expect(session.messageCount).toBe(3); + expect(session.messages[0]!.role).toBe("user"); + expect(session.messages[0]!.text).toBe("Add a health check endpoint"); + expect(session.messages[1]!.role).toBe("assistant"); + expect(session.messages[2]!.role).toBe("assistant"); + }); + + test("joins multiple Cursor text blocks in one message", () => { + const session = parseSession(cursorFixturePath); + expect(session.messages[1]!.text).toBe("Sure,\nI'll add a /health route."); + }); + + test("uses filename UUID as Cursor session id", () => { + const session = parseSession(cursorFixturePath); + expect(session.sessionId).toBe("11111111-1111-4111-8111-111111111111"); + }); + + test("uses full encoded directory as Cursor project name", () => { + const session = parseSession(cursorFixturePath); + expect(session.projectName).toBe("Users-test-GitRepos-demo-app"); + expect(session.assistantDisplayName).toBe("Cursor"); + }); + + test("uses raw epoch id as Cursor project name for opaque dirs", () => { + const session = parseSession(cursorEpochFixturePath); + expect(session.projectName).toBe("1700000000000"); + }); + + test("derives Cursor timestamps from file times", () => { + const session = parseSession(cursorFixturePath); + expect(session.startedAt).toBeTruthy(); + expect(session.endedAt).toBeTruthy(); + expect(session.startedAt <= session.endedAt!).toBe(true); + }); + + test("uses Cursor label in markdown", () => { + const session = parseSession(cursorFixturePath); + const md = session.toMarkdown(); + expect(md).toContain("# Session: Users-test-GitRepos-demo-app"); + expect(md).toContain("**Cursor ("); + expect(md).toContain("Done. Added GET /health returning 200."); + }); }); diff --git a/src/parser.ts b/src/parser.ts index 0ac0347..f30bfa7 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,4 @@ -import { readFileSync } from "fs"; +import { readFileSync, statSync } from "fs"; import { basename } from "path"; export type MessageRole = "user" | "assistant"; @@ -61,6 +61,13 @@ type CodexRecord = { }; }; +type CursorRecord = { + role?: string; + message?: { + content: string | ContentBlock[]; + }; +}; + type ContentBlock = { type: string; text?: string; @@ -85,6 +92,17 @@ function userDisplayNameFromPath(projectPath: string): string { return "User"; } +/** Cursor stores no cwd. Derive the project from the encoded directory name + * that sits immediately before `agent-transcripts/` in the file path. The name + * is the raw dash-encoded string and is intentionally NOT decoded — the + * encoding is lossy (both `/` and `.` collapse to `-`). See README caveats. */ +function cursorProjectFromPath(filePath: string): string { + const parts = filePath.split("/").filter(Boolean); + const idx = parts.indexOf("agent-transcripts"); + if (idx > 0) return parts[idx - 1]!; + return parts.length >= 2 ? parts[parts.length - 2]! : "cursor"; +} + /** Format a UTC ISO timestamp to HH:MM using UTC hours/minutes */ function formatTime(timestamp: string): string { // Use UTC slice to avoid locale/timezone issues @@ -172,6 +190,7 @@ export function parseSession(filePath: string): ParsedSession { let lastTimestamp: string | null = null; const messages: ParsedMessage[] = []; let codexFormat = false; + let cursorFormat = false; let assistantDisplayName = "Claude"; for (const line of lines) { @@ -225,6 +244,25 @@ export function parseSession(filePath: string): ParsedSession { const record = parsed as RawRecord; + // Cursor format: a top-level `role` with a `message`, and no `type` field. + // Content blocks share Claude's shape, so reuse the existing extractors. + // Project and timestamps are derived after the loop (Cursor records carry + // neither). + if (!record.type && record.message) { + const cursor = parsed as CursorRecord; + if (cursor.role === "user" || cursor.role === "assistant") { + cursorFormat = true; + const text = + cursor.role === "user" + ? extractUserText(record.message.content) + : extractAssistantText(record.message.content); + if (text) { + messages.push({ role: cursor.role, text, timestamp: "" }); + } + continue; + } + } + // Track the first sessionId we see to detect continuations. // Subagent files (path contains /subagents/) always have the parent's // sessionId in every record — this is expected, not a continuation. @@ -276,12 +314,29 @@ export function parseSession(filePath: string): ParsedSession { } } - const projectName = projectNameFromPath(projectPath); - const userDisplayName = userDisplayNameFromPath(projectPath); + let projectName = projectNameFromPath(projectPath); + let userDisplayName = userDisplayNameFromPath(projectPath); if (codexFormat && assistantDisplayName === "Claude") { assistantDisplayName = "Codex"; } + if (cursorFormat) { + assistantDisplayName = "Cursor"; + const dir = cursorProjectFromPath(filePath); + projectName = dir; + projectPath = dir; + userDisplayName = "User"; + + // Cursor transcripts have no timestamps; fall back to file times. + const stat = statSync(filePath); + const birth = stat.birthtime.getTime() ? stat.birthtime : stat.mtime; + firstTimestamp = birth.toISOString(); + lastTimestamp = stat.mtime.toISOString(); + for (const msg of messages) { + msg.timestamp = firstTimestamp; + } + } + return { sessionId, parentSessionId, diff --git a/tests/fixtures/cursor/1700000000000/agent-transcripts/22222222-2222-4222-8222-222222222222/22222222-2222-4222-8222-222222222222.jsonl b/tests/fixtures/cursor/1700000000000/agent-transcripts/22222222-2222-4222-8222-222222222222/22222222-2222-4222-8222-222222222222.jsonl new file mode 100644 index 0000000..6fecb1e --- /dev/null +++ b/tests/fixtures/cursor/1700000000000/agent-transcripts/22222222-2222-4222-8222-222222222222/22222222-2222-4222-8222-222222222222.jsonl @@ -0,0 +1 @@ +{"role":"user","message":{"content":[{"type":"text","text":"hi"}]}} diff --git a/tests/fixtures/cursor/Users-test-GitRepos-demo-app/agent-transcripts/11111111-1111-4111-8111-111111111111/11111111-1111-4111-8111-111111111111.jsonl b/tests/fixtures/cursor/Users-test-GitRepos-demo-app/agent-transcripts/11111111-1111-4111-8111-111111111111/11111111-1111-4111-8111-111111111111.jsonl new file mode 100644 index 0000000..89f1f26 --- /dev/null +++ b/tests/fixtures/cursor/Users-test-GitRepos-demo-app/agent-transcripts/11111111-1111-4111-8111-111111111111/11111111-1111-4111-8111-111111111111.jsonl @@ -0,0 +1,3 @@ +{"role":"user","message":{"content":[{"type":"text","text":"Add a health check endpoint"}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Sure,"},{"type":"text","text":"I'll add a /health route."}]}} +{"role":"assistant","message":{"content":[{"type":"text","text":"Done. Added GET /health returning 200."}]}} From 567ca34f14cff0031af363a8f5502b81be633500 Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:58:51 -0400 Subject: [PATCH 03/67] docs: session groups design spec Manual groups over coding sessions: a Groups nav tab, one group per session, in-app create/rename/delete, membership in a separate table that survives re-ingest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-19-session-groups-design.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-session-groups-design.md diff --git a/docs/superpowers/specs/2026-07-19-session-groups-design.md b/docs/superpowers/specs/2026-07-19-session-groups-design.md new file mode 100644 index 0000000..dcc17df --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-session-groups-design.md @@ -0,0 +1,125 @@ +# Session Groups — Design + +**Date:** 2026-07-19 +**Status:** Approved +**Scope:** Add a **Groups** view to the engineering-notebook web UI, letting the user create named headers and file individual coding sessions under them. + +## Motivation + +The Claude Desktop app lets a user group sessions under a named header in the sidebar. The user wants that organizing idea replicated in the notebook. Investigation established that the desktop groupings are held **server-side in the Anthropic account** and are not readable from any local file, and that the desktop "groups" (claude.ai Projects) actually organize **web-chat conversations** — a different corpus than the Claude Code / Codex **terminal transcripts** this notebook ingests. + +Decision: build **manual groups over the notebook's existing coding sessions** now. Mirroring/ingesting claude.ai web chats is a separate future spec and is **out of scope here**. + +## Requirements + +- A **Groups** tab in the top nav: `Journal · Projects · Calendar · Groups` (Groups last). +- A group is a **named header** containing **individual sessions**. +- **One group per session** (a session is filed under at most one header, like the desktop sidebar). +- Groups are created, renamed, and deleted **in-app** (no hand-editing config files). +- A session is filed into a group via an **"Add to group"** control on the **session transcript view**. +- Group membership **persists across re-ingest** (ingest deletes/recreates session rows). + +## Non-Goals (YAGNI) + +- No drag-to-reorder of groups or sessions. +- No colors, icons, or descriptions on groups. +- No bulk multi-select assignment. +- No dedicated "Ungrouped" browsing page. +- No ingestion of claude.ai web chats (separate future spec). + +## Data Model + +Two new SQLite tables, added to the existing `CREATE TABLE IF NOT EXISTS` schema block in `src/db.ts` (no migration gymnastics needed — the schema is created idempotently on init). + +```sql +CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS session_groups ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id), -- PK => one group per session + group_id INTEGER NOT NULL REFERENCES groups(id), + assigned_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_session_groups_group ON session_groups(group_id); +``` + +**Why a separate `session_groups` table, not a `group_id` column on `sessions`:** +`src/ingest.ts` runs `DELETE FROM sessions WHERE id = ?` on re-ingest (and `--force`). A column on the sessions row would be wiped. A separate membership table keyed by the stable session UUID survives, because ingest never touches it. When a session is deleted and re-ingested with the same id, its membership row still applies. + +**Cardinality:** `session_id` as PRIMARY KEY enforces one-group-per-session at the schema level. Reassigning a session is an upsert (`INSERT ... ON CONFLICT(session_id) DO UPDATE`). + +**Group deletion:** the app does not rely on SQLite foreign-key enforcement, so `deleteGroup` explicitly deletes the group's `session_groups` rows and then the `groups` row, in a transaction. + +**Orphans:** a membership row whose `session_id` no longer exists (session permanently removed) is harmless — all read queries `JOIN sessions`, so orphans are filtered out. Optional lazy prune is allowed but not required. + +## Backend + +### `src/db.ts` helpers + +- `listGroups(): GroupSummary[]` — each group with `id`, `name`, `sessionCount`, `lastActivityAt` (max of member sessions' `started_at`), ordered by `lastActivityAt` desc (nulls last). +- `createGroup(name: string): number` — insert, return id; throws / surfaces error on duplicate name (UNIQUE). +- `renameGroup(id: number, name: string): void` — surfaces duplicate-name error. +- `deleteGroup(id: number): void` — transaction: delete memberships then group. +- `assignSession(sessionId: string, groupId: number | null): void` — `null` removes membership; otherwise upsert on `session_id`. +- `getSessionGroupId(sessionId: string): number | null` — current group for rendering the control. +- `getGroupWithSessions(id: number): { group, sessions[] } | null` — group plus its member sessions (joined to `sessions` and their `projects`), ordered by `started_at` desc. Each session row carries enough to link to its transcript and, where available, its journal entry. + +### `src/web/server.ts` routes (Hono, matching the existing Settings form-POST pattern) + +- `GET /groups` — index page. +- `GET /groups/:id` — group detail; 404 if missing. +- `POST /groups` — create from form field `name`; redirect to `/groups` (or back with an error banner on duplicate/empty name). +- `POST /groups/:id/rename` — form field `name`; redirect back. +- `POST /groups/:id/delete` — redirect to `/groups`. +- `POST /sessions/:id/group` — form field `group_id` (`""` = unassign); `assignSession`; redirect back to the session view. + +Validation: empty/whitespace name rejected; duplicate name reported to the user rather than throwing a 500. + +## UI (`src/web/views/`) + +- **`layout.ts`** — add a `Groups` nav link after `Calendar`, with a `groupsActive` flag mirroring the existing `journalActive` / `projectsActive` / `calendarActive` pattern. +- **`groups.ts` (new)** — two renders: + - **Index:** a "New group" text input + Create button; a list of groups showing name, session count, and last activity, each linking to its detail page; inline rename (input + save) and delete (button with confirm) per group. + - **Detail:** group name with rename/delete controls; a list of member sessions (title, date, project) linking to the existing session transcript view and journal view; empty-state text when the group has no sessions. +- **`session.ts`** — add an **"Add to group"** `` is added to the existing session detail view. Membership lives in a separate table (no FK on `session_id`) so it survives `--force` re-ingest. + +**Tech Stack:** Bun, `bun:sqlite`, Hono, TypeScript, server-rendered HTML strings, `bun test`. + +## Global Constraints + +- Runtime: **Bun** (`bun test`, `bun --bun tsc --noEmit`). No new dependencies. +- DB: `bun:sqlite` with **`PRAGMA foreign_keys = ON`** (set in `initDb`). Therefore `session_groups.session_id` MUST NOT have a foreign key (a FK would block ingest's `DELETE FROM sessions`), and `group_id` uses `REFERENCES groups(id) ON DELETE CASCADE`. +- One group per session — enforced by `session_id TEXT PRIMARY KEY`. +- All user-supplied text rendered through `escapeHtml` from `src/web/views/helpers.ts`. +- Nav order: `Journal · Projects · Calendar · Groups` (Groups last). +- Assign control lives on the **session detail view only** (`src/web/views/session.ts`). +- Follow existing patterns: inline `db.query(...)`/`db.prepare(...)`, form POST + `c.redirect(...)` like `POST /settings`, server-rendered HTML string concatenation. +- Every code change ends green: `bun test` and `bun run typecheck` both pass before each commit. +- Pre-commit hook runs `bun test` + typecheck automatically; commits fail if either fails. +- All work on branch `feature/session-groups`. + +--- + +### Task 1: Schema — `groups` and `session_groups` tables + +**Files:** +- Modify: `src/db.ts` (add two `CREATE TABLE` statements + one index inside the existing `db.exec(\`...\`)` block, after the `journal_entries` table and before the closing `` ` ``; the trailing `CREATE INDEX` lines are at the end of the same exec) +- Test: `src/db.test.ts` + +**Interfaces:** +- Consumes: existing `initDb(dbPath: string): Database` from `src/db.ts`. +- Produces: tables `groups(id INTEGER PK, name TEXT UNIQUE, created_at TEXT)` and `session_groups(session_id TEXT PK, group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, assigned_at TEXT)`. + +- [ ] **Step 1: Write the failing test** + +Add to `src/db.test.ts` inside the `describe("db", ...)` block: + +```ts +test("initDb creates groups and session_groups tables", () => { + const db = initDb(dbPath); + const names = ( + db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[] + ).map((t) => t.name); + expect(names).toContain("groups"); + expect(names).toContain("session_groups"); +}); + +test("session_groups.session_id has no foreign key (survives session delete)", () => { + const db = initDb(dbPath); + const fks = db.query("PRAGMA foreign_key_list(session_groups)").all() as { table: string }[]; + // Only group_id -> groups may exist; sessions must NOT be referenced + expect(fks.some((f) => f.table === "sessions")).toBe(false); + expect(fks.some((f) => f.table === "groups")).toBe(true); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/db.test.ts` +Expected: FAIL — the two new tests fail (`groups`/`session_groups` not found). + +- [ ] **Step 3: Add the tables to the schema** + +In `src/db.ts`, inside the `db.exec(\`...\`)` string, immediately after the `journal_entries` `CREATE TABLE ... );` block and before the existing `CREATE INDEX IF NOT EXISTS idx_sessions_project ...` lines, insert: + +```sql + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS session_groups ( + session_id TEXT PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + assigned_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_session_groups_group ON session_groups(group_id); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/db.test.ts` +Expected: PASS (all db tests, including the two new ones). + +- [ ] **Step 5: Commit** + +```bash +git add src/db.ts src/db.test.ts +git commit -m "feat(groups): add groups and session_groups tables" +``` + +--- + +### Task 2: Group helper module — create/list/rename/delete/assign + +**Files:** +- Create: `src/groups.ts` +- Test: `src/groups.test.ts` + +**Interfaces:** +- Consumes: `initDb` from `src/db.ts`; a `Database` from `bun:sqlite`. +- Produces (exact signatures later tasks rely on): + - `type GroupSummary = { id: number; name: string; sessionCount: number; lastActivityAt: string | null }` + - `type GroupSessionRow = { id: string; display_name: string; project_id: string; started_at: string; message_count: number }` + - `createGroup(db: Database, name: string): number` — inserts, returns new id; throws `Error("Group name already exists")` on duplicate, `Error("Group name required")` on empty/whitespace. + - `listGroups(db: Database): GroupSummary[]` — ordered by `lastActivityAt` desc, nulls last, then name asc. + - `renameGroup(db: Database, id: number, name: string): void` — same validation as create. + - `deleteGroup(db: Database, id: number): void` + - `assignSession(db: Database, sessionId: string, groupId: number | null): void` — `null` removes; else upsert. + - `getSessionGroupId(db: Database, sessionId: string): number | null` + - `getGroupWithSessions(db: Database, id: number): { group: { id: number; name: string }; sessions: GroupSessionRow[] } | null` + +- [ ] **Step 1: Write the failing test** + +Create `src/groups.test.ts`: + +```ts +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { initDb, closeDb } from "./db"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { + createGroup, listGroups, renameGroup, deleteGroup, + assignSession, getSessionGroupId, getGroupWithSessions, +} from "./groups"; + +describe("groups", () => { + let tempDir: string; + let db: ReturnType; + + function seedProject(id = "proj-a") { + db.query( + "INSERT INTO projects (id, path, display_name) VALUES (?, ?, ?)" + ).run(id, "/tmp/" + id, id); + } + function seedSession(id: string, projectId = "proj-a", startedAt = "2026-07-10T12:00:00Z") { + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'))` + ).run(id, projectId, "/tmp/" + projectId, "/tmp/src.jsonl", startedAt, 5); + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "notebook-groups-test-")); + db = initDb(join(tempDir, "test.db")); + }); + afterEach(() => { + closeDb(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("createGroup then listGroups shows it with zero sessions", () => { + const id = createGroup(db, "Trading"); + const groups = listGroups(db); + expect(groups).toHaveLength(1); + expect(groups[0]!.id).toBe(id); + expect(groups[0]!.name).toBe("Trading"); + expect(groups[0]!.sessionCount).toBe(0); + expect(groups[0]!.lastActivityAt).toBeNull(); + }); + + test("createGroup rejects empty and duplicate names", () => { + createGroup(db, "Trading"); + expect(() => createGroup(db, " ")).toThrow("Group name required"); + expect(() => createGroup(db, "Trading")).toThrow("Group name already exists"); + }); + + test("assignSession files a session and updates count + lastActivity", () => { + seedProject(); + seedSession("s1", "proj-a", "2026-07-11T09:00:00Z"); + const gid = createGroup(db, "Trading"); + assignSession(db, "s1", gid); + expect(getSessionGroupId(db, "s1")).toBe(gid); + const g = listGroups(db)[0]!; + expect(g.sessionCount).toBe(1); + expect(g.lastActivityAt).toBe("2026-07-11T09:00:00Z"); + const detail = getGroupWithSessions(db, gid)!; + expect(detail.sessions.map((s) => s.id)).toEqual(["s1"]); + }); + + test("assignSession reassigns (one group per session)", () => { + seedProject(); + seedSession("s1"); + const g1 = createGroup(db, "A"); + const g2 = createGroup(db, "B"); + assignSession(db, "s1", g1); + assignSession(db, "s1", g2); + expect(getSessionGroupId(db, "s1")).toBe(g2); + expect(getGroupWithSessions(db, g1)!.sessions).toHaveLength(0); + expect(getGroupWithSessions(db, g2)!.sessions).toHaveLength(1); + }); + + test("assignSession null unassigns", () => { + seedProject(); + seedSession("s1"); + const gid = createGroup(db, "A"); + assignSession(db, "s1", gid); + assignSession(db, "s1", null); + expect(getSessionGroupId(db, "s1")).toBeNull(); + }); + + test("renameGroup rejects duplicate name", () => { + createGroup(db, "A"); + const b = createGroup(db, "B"); + expect(() => renameGroup(db, b, "A")).toThrow("Group name already exists"); + renameGroup(db, b, "B2"); + expect(listGroups(db).find((g) => g.id === b)!.name).toBe("B2"); + }); + + test("deleteGroup removes group and its memberships (cascade)", () => { + seedProject(); + seedSession("s1"); + const gid = createGroup(db, "A"); + assignSession(db, "s1", gid); + deleteGroup(db, gid); + expect(listGroups(db)).toHaveLength(0); + expect(getSessionGroupId(db, "s1")).toBeNull(); + }); + + test("membership survives session delete + re-insert (re-ingest)", () => { + seedProject(); + seedSession("s1"); + const gid = createGroup(db, "A"); + assignSession(db, "s1", gid); + // Simulate --force re-ingest: delete then re-insert same id + db.query("DELETE FROM sessions WHERE id = ?").run("s1"); + seedSession("s1"); + expect(getSessionGroupId(db, "s1")).toBe(gid); + expect(getGroupWithSessions(db, gid)!.sessions).toHaveLength(1); + }); + + test("orphan membership (deleted, not re-inserted) is filtered from detail", () => { + seedProject(); + seedSession("s1"); + const gid = createGroup(db, "A"); + assignSession(db, "s1", gid); + db.query("DELETE FROM sessions WHERE id = ?").run("s1"); + expect(getGroupWithSessions(db, gid)!.sessions).toHaveLength(0); + expect(listGroups(db)[0]!.sessionCount).toBe(0); + }); + + test("getGroupWithSessions returns null for missing group", () => { + expect(getGroupWithSessions(db, 999)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/groups.test.ts` +Expected: FAIL — `Cannot find module './groups'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/groups.ts`: + +```ts +import { Database } from "bun:sqlite"; + +export type GroupSummary = { + id: number; + name: string; + sessionCount: number; + lastActivityAt: string | null; +}; + +export type GroupSessionRow = { + id: string; + display_name: string; + project_id: string; + started_at: string; + message_count: number; +}; + +function cleanName(name: string): string { + const trimmed = (name ?? "").trim(); + if (!trimmed) throw new Error("Group name required"); + return trimmed; +} + +export function createGroup(db: Database, name: string): number { + const clean = cleanName(name); + try { + const row = db + .query("INSERT INTO groups (name, created_at) VALUES (?, datetime('now')) RETURNING id") + .get(clean) as { id: number }; + return row.id; + } catch (err) { + if (String(err).includes("UNIQUE")) throw new Error("Group name already exists"); + throw err; + } +} + +export function renameGroup(db: Database, id: number, name: string): void { + const clean = cleanName(name); + try { + db.query("UPDATE groups SET name = ? WHERE id = ?").run(clean, id); + } catch (err) { + if (String(err).includes("UNIQUE")) throw new Error("Group name already exists"); + throw err; + } +} + +export function deleteGroup(db: Database, id: number): void { + db.query("DELETE FROM groups WHERE id = ?").run(id); +} + +export function listGroups(db: Database): GroupSummary[] { + return db + .query( + `SELECT g.id, g.name, + COUNT(s.id) AS sessionCount, + MAX(s.started_at) AS lastActivityAt + FROM groups g + LEFT JOIN session_groups sg ON sg.group_id = g.id + LEFT JOIN sessions s ON s.id = sg.session_id + GROUP BY g.id + ORDER BY (lastActivityAt IS NULL), lastActivityAt DESC, g.name ASC` + ) + .all() as GroupSummary[]; +} + +export function assignSession(db: Database, sessionId: string, groupId: number | null): void { + if (groupId === null) { + db.query("DELETE FROM session_groups WHERE session_id = ?").run(sessionId); + return; + } + db.query( + `INSERT INTO session_groups (session_id, group_id, assigned_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(session_id) DO UPDATE SET group_id = excluded.group_id, assigned_at = excluded.assigned_at` + ).run(sessionId, groupId); +} + +export function getSessionGroupId(db: Database, sessionId: string): number | null { + const row = db + .query("SELECT group_id FROM session_groups WHERE session_id = ?") + .get(sessionId) as { group_id: number } | null; + return row ? row.group_id : null; +} + +export function getGroupWithSessions( + db: Database, + id: number +): { group: { id: number; name: string }; sessions: GroupSessionRow[] } | null { + const group = db.query("SELECT id, name FROM groups WHERE id = ?").get(id) as + | { id: number; name: string } + | null; + if (!group) return null; + const sessions = db + .query( + `SELECT s.id, p.display_name, s.project_id, s.started_at, s.message_count + FROM session_groups sg + JOIN sessions s ON s.id = sg.session_id + JOIN projects p ON p.id = s.project_id + WHERE sg.group_id = ? + ORDER BY s.started_at DESC` + ) + .all(id) as GroupSessionRow[]; + return { group, sessions }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/groups.test.ts` +Expected: PASS (all group helper tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/groups.ts src/groups.test.ts +git commit -m "feat(groups): add group helper module (create/list/rename/delete/assign)" +``` + +--- + +### Task 3: Nav — add Groups tab to layout + +**Files:** +- Modify: `src/web/views/layout.ts` (union types at lines 4/12/17; `activeTab` flags near line 34-36; nav block near line 708-712) +- Test: `src/web/server.test.ts` (added test added in Task 4 covers active state; this task's own check is the typecheck + a render assertion below) + +**Interfaces:** +- Consumes: `renderLayout(title, content)` existing signature. +- Produces: `renderLayout` accepts `activeTab: "groups"` on the single-body content shape; nav shows a `Groups` link, highlighted when active. + +- [ ] **Step 1: Write the failing test** + +Add to `src/web/server.test.ts` inside `describe("server", ...)` a new nested block: + +```ts +describe("Groups nav", () => { + test("layout renders a Groups nav link", async () => { + const { renderLayout } = await import("./views/layout"); + const html = renderLayout("X", { body: "

hi

", activeTab: "groups" }); + expect(html).toContain('href="/groups"'); + expect(html).toContain(">Groups<"); + // active class applied to the groups link + expect(html).toMatch(/href="\/groups"\s+class="active"/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/server.test.ts` +Expected: FAIL — either a TypeScript error on `activeTab: "groups"` or the assertions fail (no Groups link). + +- [ ] **Step 3: Implement the nav changes** + +In `src/web/views/layout.ts`: + +3a. Extend the `SingleContent` type (line 10-13) to allow `"groups"`: + +```ts +type SingleContent = { + body: string; + activeTab?: "calendar" | "groups"; +}; +``` + +3b. Add a `groupsActive` flag after the `calendarActive` line (near line 36): + +```ts + const groupsActive = activeTab === "groups"; +``` + +3c. Add the nav link after the Calendar link (near line 711): + +```ts + Calendar + Groups +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/server.test.ts && bun run typecheck` +Expected: PASS — Groups nav test green, typecheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/views/layout.ts src/web/server.test.ts +git commit -m "feat(groups): add Groups tab to nav" +``` + +--- + +### Task 4: Groups views — index and detail rendering + +**Files:** +- Create: `src/web/views/groups.ts` +- Test: `src/web/views/groups.test.ts` + +**Interfaces:** +- Consumes: `listGroups`, `getGroupWithSessions` from `src/groups.ts`; `escapeHtml` from `./helpers`. +- Produces: + - `renderGroupsIndex(db: Database, error?: string): string` + - `renderGroupDetail(db: Database, id: number): string | null` (null when the group is missing → caller returns 404) + +- [ ] **Step 1: Write the failing test** + +Create `src/web/views/groups.test.ts`: + +```ts +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { initDb, closeDb } from "../../db"; +import { createGroup, assignSession } from "../../groups"; +import { renderGroupsIndex, renderGroupDetail } from "./groups"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +describe("groups views", () => { + let tempDir: string; + let db: ReturnType; + + function seed(sessionId: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','My Project')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', '2026-07-10T00:00:00Z', 3, datetime('now'))` + ).run(sessionId); + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "notebook-groupsview-test-")); + db = initDb(join(tempDir, "test.db")); + }); + afterEach(() => { + closeDb(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("index shows create form and existing groups", () => { + createGroup(db, "Trading"); + const html = renderGroupsIndex(db); + expect(html).toContain('action="/groups"'); + expect(html).toContain("Trading"); + expect(html).toContain('href="/groups/'); + }); + + test("index escapes group names", () => { + createGroup(db, ""); + const html = renderGroupsIndex(db); + expect(html).toContain("<x>"); + expect(html).not.toContain(""); + }); + + test("index shows an error banner when provided", () => { + const html = renderGroupsIndex(db, "Group name already exists"); + expect(html).toContain("Group name already exists"); + }); + + test("detail lists member sessions with links", () => { + seed("s1"); + const gid = createGroup(db, "Trading"); + assignSession(db, "s1", gid); + const html = renderGroupDetail(db, gid)!; + expect(html).toContain("Trading"); + expect(html).toContain("My Project"); + expect(html).toContain('href="/session/s1"'); + expect(html).toContain('action="/groups/' + gid + '/rename"'); + expect(html).toContain('action="/groups/' + gid + '/delete"'); + }); + + test("detail returns null for missing group", () => { + expect(renderGroupDetail(db, 12345)).toBeNull(); + }); + + test("detail shows empty-state for a group with no sessions", () => { + const gid = createGroup(db, "Empty"); + const html = renderGroupDetail(db, gid)!; + expect(html).toContain("empty-state"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/views/groups.test.ts` +Expected: FAIL — `Cannot find module './groups'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/web/views/groups.ts`: + +```ts +import { Database } from "bun:sqlite"; +import { escapeHtml } from "./helpers"; +import { listGroups, getGroupWithSessions } from "../../groups"; + +export function renderGroupsIndex(db: Database, error?: string): string { + const groups = listGroups(db); + let html = `
`; + html += `
Groups
`; + + if (error) { + html += `
${escapeHtml(error)}
`; + } + + html += `
`; + html += ``; + html += ``; + html += `
`; + + if (groups.length === 0) { + html += `
No groups yet. Create one above.
`; + } else { + for (const g of groups) { + const activity = g.lastActivityAt ? g.lastActivityAt.slice(0, 10) : "—"; + html += `
`; + html += `${escapeHtml(g.name)}`; + html += `${g.sessionCount} session(s) · ${activity}`; + html += `
`; + } + } + + html += `
`; + return html; +} + +export function renderGroupDetail(db: Database, id: number): string | null { + const data = getGroupWithSessions(db, id); + if (!data) return null; + const { group, sessions } = data; + + let html = `
`; + html += ``; + + html += `
`; + html += ``; + html += ``; + html += `
`; + + html += `
`; + html += ``; + html += `
`; + + if (sessions.length === 0) { + html += `
No sessions in this group yet.
`; + } else { + for (const s of sessions) { + html += `
`; + html += `${escapeHtml(s.display_name)}`; + html += `
${s.started_at.slice(0, 10)} · ${s.message_count} messages · ${escapeHtml(s.project_id)}
`; + html += `
`; + } + } + + html += `
`; + return html; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/views/groups.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/views/groups.ts src/web/views/groups.test.ts +git commit -m "feat(groups): add groups index and detail views" +``` + +--- + +### Task 5: Routes — Groups pages + create/rename/delete/assign + +**Files:** +- Modify: `src/web/server.ts` (add imports near lines 3-9; add routes before `return app;` at line 277) +- Test: `src/web/server.test.ts` + +**Interfaces:** +- Consumes: `renderGroupsIndex`, `renderGroupDetail` from `./views/groups`; `createGroup`, `renameGroup`, `deleteGroup`, `assignSession` from `../groups`; existing `renderLayout`. +- Produces routes: `GET /groups`, `GET /groups/:id`, `POST /groups`, `POST /groups/:id/rename`, `POST /groups/:id/delete`, `POST /sessions/:id/group`. + +- [ ] **Step 1: Write the failing test** + +Add to `src/web/server.test.ts` inside `describe("server", ...)`: + +```ts +describe("Groups routes", () => { + function seedSession(id: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','Proj')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', '2026-07-10T00:00:00Z', 3, datetime('now'))` + ).run(id); + } + + test("GET /groups renders with Groups nav active", async () => { + const app = createApp(db, syncManager); + const res = await app.request("/groups"); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toMatch(/href="\/groups"\s+class="active"/); + expect(html).toContain("New group name"); + }); + + test("POST /groups creates a group then redirects", async () => { + const app = createApp(db, syncManager); + const form = new FormData(); + form.append("name", "Trading"); + const res = await app.request("/groups", { method: "POST", body: form }); + expect(res.status).toBe(302); + const rows = db.query("SELECT name FROM groups").all() as { name: string }[]; + expect(rows.map((r) => r.name)).toContain("Trading"); + }); + + test("POST /groups with duplicate name shows error, not 500", async () => { + const app = createApp(db, syncManager); + db.query("INSERT INTO groups (name, created_at) VALUES ('Trading', datetime('now'))").run(); + const form = new FormData(); + form.append("name", "Trading"); + const res = await app.request("/groups", { method: "POST", body: form }); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Group name already exists"); + }); + + test("GET /groups/:id lists sessions; 404 for missing", async () => { + const app = createApp(db, syncManager); + seedSession("s1"); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + db.query("INSERT INTO session_groups (session_id, group_id, assigned_at) VALUES ('s1', ?, datetime('now'))").run(gid); + const ok = await app.request("/groups/" + gid); + expect(ok.status).toBe(200); + expect(await ok.text()).toContain('href="/session/s1"'); + const missing = await app.request("/groups/99999"); + expect(missing.status).toBe(404); + }); + + test("POST /groups/:id/rename and /delete", async () => { + const app = createApp(db, syncManager); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + const rf = new FormData(); rf.append("name", "B"); + const r1 = await app.request("/groups/" + gid + "/rename", { method: "POST", body: rf }); + expect(r1.status).toBe(302); + expect((db.query("SELECT name FROM groups WHERE id = ?").get(gid) as { name: string }).name).toBe("B"); + const r2 = await app.request("/groups/" + gid + "/delete", { method: "POST", body: new FormData() }); + expect(r2.status).toBe(302); + expect(db.query("SELECT id FROM groups WHERE id = ?").get(gid)).toBeNull(); + }); + + test("POST /sessions/:id/group files and unassigns a session", async () => { + const app = createApp(db, syncManager); + seedSession("s1"); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + const assign = new FormData(); assign.append("group_id", String(gid)); + const r1 = await app.request("/sessions/s1/group", { method: "POST", body: assign }); + expect(r1.status).toBe(302); + expect((db.query("SELECT group_id FROM session_groups WHERE session_id='s1'").get() as { group_id: number }).group_id).toBe(gid); + const unassign = new FormData(); unassign.append("group_id", ""); + const r2 = await app.request("/sessions/s1/group", { method: "POST", body: unassign }); + expect(r2.status).toBe(302); + expect(db.query("SELECT group_id FROM session_groups WHERE session_id='s1'").get()).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/web/server.test.ts` +Expected: FAIL — routes return 404 (not defined). + +- [ ] **Step 3: Implement the routes** + +3a. Add imports near the other view imports (after line 8 in `src/web/server.ts`): + +```ts +import { renderGroupsIndex, renderGroupDetail } from "./views/groups"; +import { createGroup, renameGroup, deleteGroup, assignSession } from "../groups"; +``` + +3b. Insert these routes immediately before `return app;` (line 277): + +```ts + // ────────────────────────────────────────── + // Groups + // ────────────────────────────────────────── + + app.get("/groups", (c) => { + const error = c.req.query("error") || undefined; + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, error), + activeTab: "groups", + })); + }); + + app.get("/groups/:id", (c) => { + const id = parseInt(c.req.param("id"), 10); + const body = isNaN(id) ? null : renderGroupDetail(db, id); + if (!body) return c.text("Group not found", 404); + return c.html(renderLayout("Group — Engineering Notebook", { + body, + activeTab: "groups", + })); + }); + + app.post("/groups", async (c) => { + const form = await c.req.parseBody(); + try { + createGroup(db, (form.name as string) || ""); + } catch (err) { + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, String(err instanceof Error ? err.message : err)), + activeTab: "groups", + })); + } + return c.redirect("/groups"); + }); + + app.post("/groups/:id/rename", async (c) => { + const id = parseInt(c.req.param("id"), 10); + const form = await c.req.parseBody(); + try { + renameGroup(db, id, (form.name as string) || ""); + } catch (err) { + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, String(err instanceof Error ? err.message : err)), + activeTab: "groups", + })); + } + return c.redirect(`/groups/${id}`); + }); + + app.post("/groups/:id/delete", (c) => { + const id = parseInt(c.req.param("id"), 10); + if (!isNaN(id)) deleteGroup(db, id); + return c.redirect("/groups"); + }); + + app.post("/sessions/:id/group", async (c) => { + const sessionId = c.req.param("id"); + const form = await c.req.parseBody(); + const raw = (form.group_id as string) || ""; + const groupId = raw === "" ? null : parseInt(raw, 10); + assignSession(db, sessionId, groupId === null || isNaN(groupId) ? null : groupId); + return c.redirect(`/session/${encodeURIComponent(sessionId)}`); + }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/server.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/server.ts src/web/server.test.ts +git commit -m "feat(groups): add group + session-assign routes" +``` + +--- + +### Task 6: "Add to group" control on the session view + +**Files:** +- Modify: `src/web/views/session.ts` (add a select-form after the metadata header block, near line 52) +- Test: `src/web/views/session.test.ts` + +**Interfaces:** +- Consumes: `listGroups`, `getSessionGroupId` from `../../groups`. +- Produces: `renderSessionDetail` output now includes an `
` with a ``; + html += ``; + for (const g of groups) { + html += ``; + } + html += ``; + html += ``; + html += `
`; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/web/views/session.test.ts && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/views/session.ts src/web/views/session.test.ts +git commit -m "feat(groups): add 'Add to group' control to session view" +``` + +--- + +### Task 7: Full suite green + manual smoke check + +**Files:** +- No new production files. Verification only. + +**Interfaces:** +- Consumes: everything from Tasks 1-6. + +- [ ] **Step 1: Run the whole suite + typecheck** + +Run: `bun run check` +Expected: all tests pass; `All checks passed.` from typecheck. + +- [ ] **Step 2: Smoke-test the running server** + +Run (server on a free port, since 3000 may be occupied): + +```bash +engineering-notebook serve --port 3737 & +sleep 1 +curl -s -o /dev/null -w "groups=%{http_code}\n" http://localhost:3737/groups +curl -s -X POST -F "name=Smoke Test" -o /dev/null -w "create=%{http_code}\n" http://localhost:3737/groups +curl -s http://localhost:3737/groups | grep -o "Smoke Test" | head -1 +kill %1 +``` + +Expected: `groups=200`, `create=302`, and `Smoke Test` printed (group created and listed). + +- [ ] **Step 3: Commit any final touch-ups (if needed)** + +Only if Step 1/2 surfaced a fix. Otherwise skip. + +```bash +git add -A +git commit -m "chore(groups): finalize session groups feature" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Groups nav tab (Groups last) → Task 3. ✔ +- Named header containing individual sessions → Tasks 1, 2, 4. ✔ +- One group per session (PK) → Task 1 schema + Task 2 reassign test. ✔ +- In-app create/rename/delete → Tasks 4, 5. ✔ +- Assign via control on session view → Task 6. ✔ +- Membership survives re-ingest (no FK on session_id) → Task 1 schema + Task 2 re-ingest test. ✔ +- Duplicate/empty name rejected without 500 → Task 2 (helper throws) + Task 5 (route renders error, status 200). ✔ +- Ordering (groups by last activity, sessions by recency) → Task 2 queries. ✔ +- Orphan membership filtered → Task 2 orphan test (JOIN sessions). ✔ +- Out of scope (web chats, colors, bulk, reorder, ungrouped page) → not built. ✔ + +**Placeholder scan:** No TBD/TODO; every code step shows full code; every test step shows the assertions. + +**Type consistency:** Helper signatures in Task 2's Interfaces (`createGroup`, `listGroups`, `renameGroup`, `deleteGroup`, `assignSession`, `getSessionGroupId`, `getGroupWithSessions`) match their use in Tasks 4, 5, 6. View function names (`renderGroupsIndex`, `renderGroupDetail`) match between Tasks 4 and 5. `activeTab: "groups"` added in Task 3 matches its use in Task 5 routes. Route paths (`/groups`, `/groups/:id`, `/groups/:id/rename`, `/groups/:id/delete`, `/sessions/:id/group`) match between Task 5 routes, Task 4 detail form actions, and Task 6 assign form. diff --git a/docs/superpowers/specs/2026-07-19-session-groups-design.md b/docs/superpowers/specs/2026-07-19-session-groups-design.md index dcc17df..6d57d1c 100644 --- a/docs/superpowers/specs/2026-07-19-session-groups-design.md +++ b/docs/superpowers/specs/2026-07-19-session-groups-design.md @@ -39,22 +39,24 @@ CREATE TABLE IF NOT EXISTS groups ( ); CREATE TABLE IF NOT EXISTS session_groups ( - session_id TEXT PRIMARY KEY REFERENCES sessions(id), -- PK => one group per session - group_id INTEGER NOT NULL REFERENCES groups(id), + session_id TEXT PRIMARY KEY, -- one group per session; NO FK (see below) + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, assigned_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_session_groups_group ON session_groups(group_id); ``` -**Why a separate `session_groups` table, not a `group_id` column on `sessions`:** -`src/ingest.ts` runs `DELETE FROM sessions WHERE id = ?` on re-ingest (and `--force`). A column on the sessions row would be wiped. A separate membership table keyed by the stable session UUID survives, because ingest never touches it. When a session is deleted and re-ingested with the same id, its membership row still applies. +**Foreign-key enforcement is ON.** `initDb` runs `PRAGMA foreign_keys = ON`. This is the constraint that shapes the schema: + +- **`session_id` has NO foreign key** (deliberately). `src/ingest.ts` runs `DELETE FROM sessions WHERE id = ?` on `--force` re-ingest. If `session_id` referenced `sessions(id)`, that DELETE would be blocked by the FK (RESTRICT) and re-ingest would throw. Keeping `session_id` a bare `TEXT PRIMARY KEY` means ingest never touches membership, so it survives re-ingest. When a session is deleted and re-inserted with the same UUID, its membership row still applies. +- **`group_id` references `groups(id) ON DELETE CASCADE`.** With FK enforcement on, deleting a group automatically removes its membership rows — no manual cleanup needed. **Cardinality:** `session_id` as PRIMARY KEY enforces one-group-per-session at the schema level. Reassigning a session is an upsert (`INSERT ... ON CONFLICT(session_id) DO UPDATE`). -**Group deletion:** the app does not rely on SQLite foreign-key enforcement, so `deleteGroup` explicitly deletes the group's `session_groups` rows and then the `groups` row, in a transaction. +**Group deletion:** `deleteGroup` is a plain `DELETE FROM groups WHERE id = ?`; the `ON DELETE CASCADE` clears memberships. -**Orphans:** a membership row whose `session_id` no longer exists (session permanently removed) is harmless — all read queries `JOIN sessions`, so orphans are filtered out. Optional lazy prune is allowed but not required. +**Orphans:** a membership row whose `session_id` no longer exists (session permanently removed, not re-ingested) is harmless — all read queries `JOIN sessions`, so orphans are filtered out. Optional lazy prune is allowed but not required. ## Backend @@ -63,7 +65,7 @@ CREATE INDEX IF NOT EXISTS idx_session_groups_group ON session_groups(group_id); - `listGroups(): GroupSummary[]` — each group with `id`, `name`, `sessionCount`, `lastActivityAt` (max of member sessions' `started_at`), ordered by `lastActivityAt` desc (nulls last). - `createGroup(name: string): number` — insert, return id; throws / surfaces error on duplicate name (UNIQUE). - `renameGroup(id: number, name: string): void` — surfaces duplicate-name error. -- `deleteGroup(id: number): void` — transaction: delete memberships then group. +- `deleteGroup(id: number): void` — `DELETE FROM groups WHERE id = ?` (cascade clears memberships). - `assignSession(sessionId: string, groupId: number | null): void` — `null` removes membership; otherwise upsert on `session_id`. - `getSessionGroupId(sessionId: string): number | null` — current group for rendering the control. - `getGroupWithSessions(id: number): { group, sessions[] } | null` — group plus its member sessions (joined to `sessions` and their `projects`), ordered by `started_at` desc. Each session row carries enough to link to its transcript and, where available, its journal entry. From 20fef481de233f754ba4129c859c7b043cc18cce Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:07:25 -0400 Subject: [PATCH 05/67] feat(groups): add groups and session_groups tables --- src/db.test.ts | 17 +++++++++++++++++ src/db.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/db.test.ts b/src/db.test.ts index 049c599..e973ea6 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -55,4 +55,21 @@ describe("db", () => { const result = db.query("SELECT 1 as ok").get() as { ok: number }; expect(result.ok).toBe(1); }); + + test("initDb creates groups and session_groups tables", () => { + const db = initDb(dbPath); + const names = ( + db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[] + ).map((t) => t.name); + expect(names).toContain("groups"); + expect(names).toContain("session_groups"); + }); + + test("session_groups.session_id has no foreign key (survives session delete)", () => { + const db = initDb(dbPath); + const fks = db.query("PRAGMA foreign_key_list(session_groups)").all() as { table: string }[]; + // Only group_id -> groups may exist; sessions must NOT be referenced + expect(fks.some((f) => f.table === "sessions")).toBe(false); + expect(fks.some((f) => f.table === "groups")).toBe(true); + }); }); diff --git a/src/db.ts b/src/db.ts index 59cf29c..cfe2e94 100644 --- a/src/db.ts +++ b/src/db.ts @@ -61,6 +61,19 @@ export function initDb(dbPath: string): Database { UNIQUE(date, project_id) ); + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS session_groups ( + session_id TEXT PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + assigned_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_session_groups_group ON session_groups(group_id); CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id); CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at); CREATE INDEX IF NOT EXISTS idx_journal_date ON journal_entries(date); From fbd7734241aa389739c65a86338d72b6b3714842 Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:10:29 -0400 Subject: [PATCH 06/67] feat(groups): add group helper module (create/list/rename/delete/assign) --- src/groups.test.ts | 129 +++++++++++++++++++++++++++++++++++++++++++++ src/groups.ts | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 src/groups.test.ts create mode 100644 src/groups.ts diff --git a/src/groups.test.ts b/src/groups.test.ts new file mode 100644 index 0000000..2e4de16 --- /dev/null +++ b/src/groups.test.ts @@ -0,0 +1,129 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { initDb, closeDb } from "./db"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { + createGroup, listGroups, renameGroup, deleteGroup, + assignSession, getSessionGroupId, getGroupWithSessions, +} from "./groups"; + +describe("groups", () => { + let tempDir: string; + let db: ReturnType; + + function seedProject(id = "proj-a") { + db.query( + "INSERT INTO projects (id, path, display_name) VALUES (?, ?, ?)" + ).run(id, "/tmp/" + id, id); + } + function seedSession(id: string, projectId = "proj-a", startedAt = "2026-07-10T12:00:00Z") { + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'))` + ).run(id, projectId, "/tmp/" + projectId, "/tmp/src.jsonl", startedAt, 5); + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "notebook-groups-test-")); + db = initDb(join(tempDir, "test.db")); + }); + afterEach(() => { + closeDb(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("createGroup then listGroups shows it with zero sessions", () => { + const id = createGroup(db, "Trading"); + const groups = listGroups(db); + expect(groups).toHaveLength(1); + expect(groups[0]!.id).toBe(id); + expect(groups[0]!.name).toBe("Trading"); + expect(groups[0]!.sessionCount).toBe(0); + expect(groups[0]!.lastActivityAt).toBeNull(); + }); + + test("createGroup rejects empty and duplicate names", () => { + createGroup(db, "Trading"); + expect(() => createGroup(db, " ")).toThrow("Group name required"); + expect(() => createGroup(db, "Trading")).toThrow("Group name already exists"); + }); + + test("assignSession files a session and updates count + lastActivity", () => { + seedProject(); + seedSession("s1", "proj-a", "2026-07-11T09:00:00Z"); + const gid = createGroup(db, "Trading"); + assignSession(db, "s1", gid); + expect(getSessionGroupId(db, "s1")).toBe(gid); + const g = listGroups(db)[0]!; + expect(g.sessionCount).toBe(1); + expect(g.lastActivityAt).toBe("2026-07-11T09:00:00Z"); + const detail = getGroupWithSessions(db, gid)!; + expect(detail.sessions.map((s) => s.id)).toEqual(["s1"]); + }); + + test("assignSession reassigns (one group per session)", () => { + seedProject(); + seedSession("s1"); + const g1 = createGroup(db, "A"); + const g2 = createGroup(db, "B"); + assignSession(db, "s1", g1); + assignSession(db, "s1", g2); + expect(getSessionGroupId(db, "s1")).toBe(g2); + expect(getGroupWithSessions(db, g1)!.sessions).toHaveLength(0); + expect(getGroupWithSessions(db, g2)!.sessions).toHaveLength(1); + }); + + test("assignSession null unassigns", () => { + seedProject(); + seedSession("s1"); + const gid = createGroup(db, "A"); + assignSession(db, "s1", gid); + assignSession(db, "s1", null); + expect(getSessionGroupId(db, "s1")).toBeNull(); + }); + + test("renameGroup rejects duplicate name", () => { + createGroup(db, "A"); + const b = createGroup(db, "B"); + expect(() => renameGroup(db, b, "A")).toThrow("Group name already exists"); + renameGroup(db, b, "B2"); + expect(listGroups(db).find((g) => g.id === b)!.name).toBe("B2"); + }); + + test("deleteGroup removes group and its memberships (cascade)", () => { + seedProject(); + seedSession("s1"); + const gid = createGroup(db, "A"); + assignSession(db, "s1", gid); + deleteGroup(db, gid); + expect(listGroups(db)).toHaveLength(0); + expect(getSessionGroupId(db, "s1")).toBeNull(); + }); + + test("membership survives session delete + re-insert (re-ingest)", () => { + seedProject(); + seedSession("s1"); + const gid = createGroup(db, "A"); + assignSession(db, "s1", gid); + // Simulate --force re-ingest: delete then re-insert same id + db.query("DELETE FROM sessions WHERE id = ?").run("s1"); + seedSession("s1"); + expect(getSessionGroupId(db, "s1")).toBe(gid); + expect(getGroupWithSessions(db, gid)!.sessions).toHaveLength(1); + }); + + test("orphan membership (deleted, not re-inserted) is filtered from detail", () => { + seedProject(); + seedSession("s1"); + const gid = createGroup(db, "A"); + assignSession(db, "s1", gid); + db.query("DELETE FROM sessions WHERE id = ?").run("s1"); + expect(getGroupWithSessions(db, gid)!.sessions).toHaveLength(0); + expect(listGroups(db)[0]!.sessionCount).toBe(0); + }); + + test("getGroupWithSessions returns null for missing group", () => { + expect(getGroupWithSessions(db, 999)).toBeNull(); + }); +}); diff --git a/src/groups.ts b/src/groups.ts new file mode 100644 index 0000000..31ed15e --- /dev/null +++ b/src/groups.ts @@ -0,0 +1,104 @@ +import { Database } from "bun:sqlite"; + +export type GroupSummary = { + id: number; + name: string; + sessionCount: number; + lastActivityAt: string | null; +}; + +export type GroupSessionRow = { + id: string; + display_name: string; + project_id: string; + started_at: string; + message_count: number; +}; + +function cleanName(name: string): string { + const trimmed = (name ?? "").trim(); + if (!trimmed) throw new Error("Group name required"); + return trimmed; +} + +export function createGroup(db: Database, name: string): number { + const clean = cleanName(name); + try { + const row = db + .query("INSERT INTO groups (name, created_at) VALUES (?, datetime('now')) RETURNING id") + .get(clean) as { id: number }; + return row.id; + } catch (err) { + if (String(err).includes("UNIQUE")) throw new Error("Group name already exists"); + throw err; + } +} + +export function renameGroup(db: Database, id: number, name: string): void { + const clean = cleanName(name); + try { + db.query("UPDATE groups SET name = ? WHERE id = ?").run(clean, id); + } catch (err) { + if (String(err).includes("UNIQUE")) throw new Error("Group name already exists"); + throw err; + } +} + +export function deleteGroup(db: Database, id: number): void { + db.query("DELETE FROM groups WHERE id = ?").run(id); +} + +export function listGroups(db: Database): GroupSummary[] { + return db + .query( + `SELECT g.id, g.name, + COUNT(s.id) AS sessionCount, + MAX(s.started_at) AS lastActivityAt + FROM groups g + LEFT JOIN session_groups sg ON sg.group_id = g.id + LEFT JOIN sessions s ON s.id = sg.session_id + GROUP BY g.id + ORDER BY (lastActivityAt IS NULL), lastActivityAt DESC, g.name ASC` + ) + .all() as GroupSummary[]; +} + +export function assignSession(db: Database, sessionId: string, groupId: number | null): void { + if (groupId === null) { + db.query("DELETE FROM session_groups WHERE session_id = ?").run(sessionId); + return; + } + db.query( + `INSERT INTO session_groups (session_id, group_id, assigned_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(session_id) DO UPDATE SET group_id = excluded.group_id, assigned_at = excluded.assigned_at` + ).run(sessionId, groupId); +} + +export function getSessionGroupId(db: Database, sessionId: string): number | null { + const row = db + .query("SELECT group_id FROM session_groups WHERE session_id = ?") + .get(sessionId) as { group_id: number } | null; + return row ? row.group_id : null; +} + +export function getGroupWithSessions( + db: Database, + id: number +): { group: { id: number; name: string }; sessions: GroupSessionRow[] } | null { + const group = db.query("SELECT id, name FROM groups WHERE id = ?").get(id) as + | { id: number; name: string } + | null; + if (!group) return null; + const sessions = db + .query( + `SELECT s.id, p.display_name, s.project_id, s.started_at, s.message_count + FROM session_groups sg + JOIN sessions s ON s.id = sg.session_id + JOIN projects p ON p.id = s.project_id + WHERE sg.group_id = ? + ORDER BY s.started_at DESC` + ) + .all(id) as GroupSessionRow[]; + return { group, sessions }; +} From ad479cc261eab9855e0b291f9f5ce75423ccef63 Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:15:25 -0400 Subject: [PATCH 07/67] feat(groups): add Groups tab to nav Co-Authored-By: Claude Opus 4.8 (1M context) --- src/web/server.test.ts | 11 +++++++++++ src/web/views/layout.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/web/server.test.ts b/src/web/server.test.ts index 4f7390f..6141ca9 100644 --- a/src/web/server.test.ts +++ b/src/web/server.test.ts @@ -182,4 +182,15 @@ describe("server", () => { expect(html).toContain("sync-status-panel"); }); }); + + describe("Groups nav", () => { + test("layout renders a Groups nav link", async () => { + const { renderLayout } = await import("./views/layout"); + const html = renderLayout("X", { body: "

hi

", activeTab: "groups" }); + expect(html).toContain('href="/groups"'); + expect(html).toContain(">Groups<"); + // active class applied to the groups link + expect(html).toMatch(/href="\/groups"\s+class="active"/); + }); + }); }); diff --git a/src/web/views/layout.ts b/src/web/views/layout.ts index 8b7f2a9..28370be 100644 --- a/src/web/views/layout.ts +++ b/src/web/views/layout.ts @@ -9,7 +9,7 @@ type ThreePanelContent = { type SingleContent = { body: string; - activeTab?: "calendar"; + activeTab?: "calendar" | "groups"; }; type FullWidthContent = { @@ -34,6 +34,7 @@ export function renderLayout(title: string, content: LayoutContent): string { const journalActive = activeTab === "journal"; const projectsActive = activeTab === "projects"; const calendarActive = activeTab === "calendar"; + const groupsActive = activeTab === "groups"; let bodyHtml: string; if (isThreePanel(content)) { @@ -709,6 +710,7 @@ export function renderLayout(title: string, content: LayoutContent): string { Journal Projects Calendar + Groups
From 4c212d11de1873aa7452177f2ddddf3439dc8ae7 Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:18:09 -0400 Subject: [PATCH 08/67] feat(groups): add groups index and detail views --- src/web/views/groups.test.ts | 71 ++++++++++++++++++++++++++++++++++++ src/web/views/groups.ts | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/web/views/groups.test.ts create mode 100644 src/web/views/groups.ts diff --git a/src/web/views/groups.test.ts b/src/web/views/groups.test.ts new file mode 100644 index 0000000..32a17b5 --- /dev/null +++ b/src/web/views/groups.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { initDb, closeDb } from "../../db"; +import { createGroup, assignSession } from "../../groups"; +import { renderGroupsIndex, renderGroupDetail } from "./groups"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +describe("groups views", () => { + let tempDir: string; + let db: ReturnType; + + function seed(sessionId: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','My Project')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', '2026-07-10T00:00:00Z', 3, datetime('now'))` + ).run(sessionId); + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "notebook-groupsview-test-")); + db = initDb(join(tempDir, "test.db")); + }); + afterEach(() => { + closeDb(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("index shows create form and existing groups", () => { + createGroup(db, "Trading"); + const html = renderGroupsIndex(db); + expect(html).toContain('action="/groups"'); + expect(html).toContain("Trading"); + expect(html).toContain('href="/groups/'); + }); + + test("index escapes group names", () => { + createGroup(db, ""); + const html = renderGroupsIndex(db); + expect(html).toContain("<x>"); + expect(html).not.toContain(""); + }); + + test("index shows an error banner when provided", () => { + const html = renderGroupsIndex(db, "Group name already exists"); + expect(html).toContain("Group name already exists"); + }); + + test("detail lists member sessions with links", () => { + seed("s1"); + const gid = createGroup(db, "Trading"); + assignSession(db, "s1", gid); + const html = renderGroupDetail(db, gid)!; + expect(html).toContain("Trading"); + expect(html).toContain("My Project"); + expect(html).toContain('href="/session/s1"'); + expect(html).toContain('action="/groups/' + gid + '/rename"'); + expect(html).toContain('action="/groups/' + gid + '/delete"'); + }); + + test("detail returns null for missing group", () => { + expect(renderGroupDetail(db, 12345)).toBeNull(); + }); + + test("detail shows empty-state for a group with no sessions", () => { + const gid = createGroup(db, "Empty"); + const html = renderGroupDetail(db, gid)!; + expect(html).toContain("empty-state"); + }); +}); diff --git a/src/web/views/groups.ts b/src/web/views/groups.ts new file mode 100644 index 0000000..ccf45c4 --- /dev/null +++ b/src/web/views/groups.ts @@ -0,0 +1,65 @@ +import { Database } from "bun:sqlite"; +import { escapeHtml } from "./helpers"; +import { listGroups, getGroupWithSessions } from "../../groups"; + +export function renderGroupsIndex(db: Database, error?: string): string { + const groups = listGroups(db); + let html = `
`; + html += `
Groups
`; + + if (error) { + html += `
${escapeHtml(error)}
`; + } + + html += ``; + html += ``; + html += ``; + html += ``; + + if (groups.length === 0) { + html += `
No groups yet. Create one above.
`; + } else { + for (const g of groups) { + const activity = g.lastActivityAt ? g.lastActivityAt.slice(0, 10) : "—"; + html += `
`; + html += `${escapeHtml(g.name)}`; + html += `${g.sessionCount} session(s) · ${activity}`; + html += `
`; + } + } + + html += `
`; + return html; +} + +export function renderGroupDetail(db: Database, id: number): string | null { + const data = getGroupWithSessions(db, id); + if (!data) return null; + const { group, sessions } = data; + + let html = `
`; + html += ``; + + html += `
`; + html += ``; + html += ``; + html += `
`; + + html += `
`; + html += ``; + html += `
`; + + if (sessions.length === 0) { + html += `
No sessions in this group yet.
`; + } else { + for (const s of sessions) { + html += `
`; + html += `${escapeHtml(s.display_name)}`; + html += `
${s.started_at.slice(0, 10)} · ${s.message_count} messages · ${escapeHtml(s.project_id)}
`; + html += `
`; + } + } + + html += `
`; + return html; +} From a770550570f70d90b64786f38d72fc913f8e984f Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:21:51 -0400 Subject: [PATCH 09/67] feat(groups): add group + session-assign routes --- src/web/server.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++++ src/web/server.ts | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/web/server.test.ts b/src/web/server.test.ts index 6141ca9..bb4527e 100644 --- a/src/web/server.test.ts +++ b/src/web/server.test.ts @@ -193,4 +193,82 @@ describe("server", () => { expect(html).toMatch(/href="\/groups"\s+class="active"/); }); }); + + describe("Groups routes", () => { + function seedSession(id: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','Proj')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', '2026-07-10T00:00:00Z', 3, datetime('now'))` + ).run(id); + } + + test("GET /groups renders with Groups nav active", async () => { + const app = createApp(db, syncManager); + const res = await app.request("/groups"); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toMatch(/href="\/groups"\s+class="active"/); + expect(html).toContain("New group name"); + }); + + test("POST /groups creates a group then redirects", async () => { + const app = createApp(db, syncManager); + const form = new FormData(); + form.append("name", "Trading"); + const res = await app.request("/groups", { method: "POST", body: form }); + expect(res.status).toBe(302); + const rows = db.query("SELECT name FROM groups").all() as { name: string }[]; + expect(rows.map((r) => r.name)).toContain("Trading"); + }); + + test("POST /groups with duplicate name shows error, not 500", async () => { + const app = createApp(db, syncManager); + db.query("INSERT INTO groups (name, created_at) VALUES ('Trading', datetime('now'))").run(); + const form = new FormData(); + form.append("name", "Trading"); + const res = await app.request("/groups", { method: "POST", body: form }); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Group name already exists"); + }); + + test("GET /groups/:id lists sessions; 404 for missing", async () => { + const app = createApp(db, syncManager); + seedSession("s1"); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + db.query("INSERT INTO session_groups (session_id, group_id, assigned_at) VALUES ('s1', ?, datetime('now'))").run(gid); + const ok = await app.request("/groups/" + gid); + expect(ok.status).toBe(200); + expect(await ok.text()).toContain('href="/session/s1"'); + const missing = await app.request("/groups/99999"); + expect(missing.status).toBe(404); + }); + + test("POST /groups/:id/rename and /delete", async () => { + const app = createApp(db, syncManager); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + const rf = new FormData(); rf.append("name", "B"); + const r1 = await app.request("/groups/" + gid + "/rename", { method: "POST", body: rf }); + expect(r1.status).toBe(302); + expect((db.query("SELECT name FROM groups WHERE id = ?").get(gid) as { name: string }).name).toBe("B"); + const r2 = await app.request("/groups/" + gid + "/delete", { method: "POST", body: new FormData() }); + expect(r2.status).toBe(302); + expect(db.query("SELECT id FROM groups WHERE id = ?").get(gid)).toBeNull(); + }); + + test("POST /sessions/:id/group files and unassigns a session", async () => { + const app = createApp(db, syncManager); + seedSession("s1"); + const gid = (db.query("INSERT INTO groups (name, created_at) VALUES ('A', datetime('now')) RETURNING id").get() as { id: number }).id; + const assign = new FormData(); assign.append("group_id", String(gid)); + const r1 = await app.request("/sessions/s1/group", { method: "POST", body: assign }); + expect(r1.status).toBe(302); + expect((db.query("SELECT group_id FROM session_groups WHERE session_id='s1'").get() as { group_id: number }).group_id).toBe(gid); + const unassign = new FormData(); unassign.append("group_id", ""); + const r2 = await app.request("/sessions/s1/group", { method: "POST", body: unassign }); + expect(r2.status).toBe(302); + expect(db.query("SELECT group_id FROM session_groups WHERE session_id='s1'").get()).toBeNull(); + }); + }); }); diff --git a/src/web/server.ts b/src/web/server.ts index 7205a51..b34eb15 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -7,6 +7,8 @@ import { renderSearch, renderSearchResults } from "./views/search"; import { renderSettings, renderRemoteSourceCard, renderSyncStatus } from "./views/settings"; import { renderSessionDetail } from "./views/session"; import { renderCalendarPage, renderIcalFeed, weekMonday } from "./views/calendar"; +import { renderGroupsIndex, renderGroupDetail } from "./views/groups"; +import { createGroup, renameGroup, deleteGroup, assignSession } from "../groups"; import { escapeHtml } from "./views/helpers"; import { loadConfig, saveConfig, resolveConfigPath, type RemoteSource } from "../config"; import type { SyncManager } from "../sync"; @@ -274,5 +276,69 @@ export function createApp(db: Database, syncManager: SyncManager): Hono { return c.html(`Connected`); }); + // ────────────────────────────────────────── + // Groups + // ────────────────────────────────────────── + + app.get("/groups", (c) => { + const error = c.req.query("error") || undefined; + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, error), + activeTab: "groups", + })); + }); + + app.get("/groups/:id", (c) => { + const id = parseInt(c.req.param("id"), 10); + const body = isNaN(id) ? null : renderGroupDetail(db, id); + if (!body) return c.text("Group not found", 404); + return c.html(renderLayout("Group — Engineering Notebook", { + body, + activeTab: "groups", + })); + }); + + app.post("/groups", async (c) => { + const form = await c.req.parseBody(); + try { + createGroup(db, (form.name as string) || ""); + } catch (err) { + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, String(err instanceof Error ? err.message : err)), + activeTab: "groups", + })); + } + return c.redirect("/groups"); + }); + + app.post("/groups/:id/rename", async (c) => { + const id = parseInt(c.req.param("id"), 10); + const form = await c.req.parseBody(); + try { + renameGroup(db, id, (form.name as string) || ""); + } catch (err) { + return c.html(renderLayout("Groups — Engineering Notebook", { + body: renderGroupsIndex(db, String(err instanceof Error ? err.message : err)), + activeTab: "groups", + })); + } + return c.redirect(`/groups/${id}`); + }); + + app.post("/groups/:id/delete", (c) => { + const id = parseInt(c.req.param("id"), 10); + if (!isNaN(id)) deleteGroup(db, id); + return c.redirect("/groups"); + }); + + app.post("/sessions/:id/group", async (c) => { + const sessionId = c.req.param("id"); + const form = await c.req.parseBody(); + const raw = (form.group_id as string) || ""; + const groupId = raw === "" ? null : parseInt(raw, 10); + assignSession(db, sessionId, groupId === null || isNaN(groupId) ? null : groupId); + return c.redirect(`/session/${encodeURIComponent(sessionId)}`); + }); + return app; } From 1bbe0b5d26a711fb0327c5ab53e3a3bc7e61f84a Mon Sep 17 00:00:00 2001 From: artcashin <64659740+artcashin@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:26:12 -0400 Subject: [PATCH 10/67] feat(groups): add 'Add to group' control to session view --- src/web/views/session.test.ts | 49 +++++++++++++++++++++++++++++++++++ src/web/views/session.ts | 14 ++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/web/views/session.test.ts b/src/web/views/session.test.ts index b52cd63..6a5fc5f 100644 --- a/src/web/views/session.test.ts +++ b/src/web/views/session.test.ts @@ -1,5 +1,12 @@ import { describe, test, expect } from "bun:test"; import { renderSessionFooter } from "./session"; +import { beforeEach, afterEach } from "bun:test"; +import { initDb, closeDb } from "../../db"; +import { createGroup, assignSession } from "../../groups"; +import { renderSessionDetail } from "./session"; +import { mkdtempSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; describe("renderSessionFooter", () => { test("renders Claude resume command for Claude session source paths", () => { @@ -24,3 +31,45 @@ describe("renderSessionFooter", () => { expect(html).not.toContain("claude --resume"); }); }); + +describe("session detail group control", () => { + let tempDir: string; + let db: ReturnType; + + function seed(id: string) { + db.query("INSERT OR IGNORE INTO projects (id, path, display_name) VALUES ('p','/tmp/p','Proj')").run(); + db.query( + `INSERT INTO sessions (id, project_id, project_path, source_path, started_at, message_count, ingested_at) + VALUES (?, 'p', '/tmp/p', '/tmp/s.jsonl', '2026-07-10T00:00:00Z', 3, datetime('now'))` + ).run(id); + db.query("INSERT INTO conversations (session_id, conversation_markdown, extracted_at) VALUES (?, '# hi', datetime('now'))").run(id); + } + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "notebook-sessionctl-test-")); + db = initDb(join(tempDir, "test.db")); + }); + afterEach(() => { + closeDb(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("renders an assign form with a None option and each group", () => { + seed("s1"); + createGroup(db, "Trading"); + createGroup(db, "Infra"); + const html = renderSessionDetail(db, "s1"); + expect(html).toContain('action="/sessions/s1/group"'); + expect(html).toContain("None"); + expect(html).toContain("Trading"); + expect(html).toContain("Infra"); + }); + + test("preselects the session's current group", () => { + seed("s1"); + const gid = createGroup(db, "Trading"); + assignSession(db, "s1", gid); + const html = renderSessionDetail(db, "s1"); + expect(html).toMatch(new RegExp(`