Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
56ab178
feat: prompt library — saved prompts with `/` composer access
leon0399 Jul 5, 2026
62446c1
feat(web): prompt templating — {{placeholder}} variables
leon0399 Jul 5, 2026
f92c884
chore(api): regenerate openapi.json for the prompts routes
leon0399 Jul 5, 2026
cf8dca9
Merge remote-tracking branch 'origin/master' into stack/split-prompt-…
leon0399 Jul 5, 2026
9664c15
fix(api): harden prompt writes and give prompts its own module
leon0399 Jul 6, 2026
ed0bb5d
fix(web): guard prompt composer Enter-handling against IME composition
leon0399 Jul 6, 2026
f4632f7
fix(web): accurate prompt-save errors and client-side name-length check
leon0399 Jul 6, 2026
71799e0
docs: fix prompt-library design doc's stale templating non-goal
leon0399 Jul 6, 2026
db8827d
chore(api): regenerate openapi.json after the PromptsModule extraction
leon0399 Jul 6, 2026
5f0e8d6
docs: correct prompts RLS test count in changelog (5 -> 7)
leon0399 Jul 6, 2026
0d6d162
fix(api): widen the prompt-cap advisory lock key to 64 bits
leon0399 Jul 6, 2026
e9a5596
chore(deps): move sonner into the pnpm-workspace catalog
leon0399 Jul 6, 2026
f41e478
Merge origin/master into stack/split-prompt-library
leon0399 Jul 6, 2026
b474693
Merge origin/master into stack/split-prompt-library
leon0399 Jul 6, 2026
14980c3
Merge origin/master into stack/split-prompt-library
leon0399 Jul 6, 2026
c2e3559
Merge origin/master into stack/split-prompt-library
leon0399 Jul 10, 2026
9a821b9
chore(api): regenerate openapi.json after merge
leon0399 Jul 10, 2026
c840d93
Merge origin/master into stack/split-prompt-library
leon0399 Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ _Reverse-chronological record of shipped work — features, fixes, and chores. N

# 2026-07-05

- Prompt library — saved, reusable prompt templates inserted in the composer by typing `/<name>` (the roadmap v0.5 slash-commands seed, in its genuinely-useful form; validated by Open WebUI, which has the same `/` prompt menu). A `prompts` table (owner-scoped, FORCE RLS, `UNIQUE(user_id, name)`, migration 0015) + `/api/v1/me/prompts` CRUD (GET/POST/PATCH/DELETE), mirroring the memories pattern — the `name` is a slug (`^[A-Za-z0-9_-]+$`, DB CHECK) so `/<name>` is unambiguous, and a duplicate name is a real 409 (DB unique + a `23505`-walking catch, stricter than the memories pre-check). Manage prompts in Settings; insert them in a chat via a `/` autocomplete menu. A two-reviewer round hardened the composer: the trigger is `^/(\S+)$` — bare `/` never opens the menu, so a literal `/` message still sends (the adversarial reviewer's literal-send trap); prefix matching is case-insensitive; only BARE Enter selects (Shift+Enter falls through to a newline); the menu list comes from a single shared `usePromptsQuery()` (fetched once, filtered per keystroke, invalidated by a settings edit). The core-composer change is minimal + backward-compatible: `PromptInputTextarea` now calls a passed `onKeyDown` first and bails if it `preventDefault`ed (before that, a passed handler silently clobbered Enter-to-send). Verified: 7 prompts RLS integration cases (owner CRUD, cross-tenant denied, per-user unique name but same-name-across-users allowed, a case-insensitive name conflict for the same user, slug + content CHECKs, and a concurrent-create race proving the per-user cap holds under `pg_advisory_xact_lock`), plus pure-trigger and web service cases, prompts route in openapi.json, api + web build/lint/tsc clean.
- Prompt templating — saved prompts can now carry `{{placeholder}}` variables, completing the prompt library into genuinely reusable, parameterized prompts. When you insert a prompt that has placeholders (via `/name`), a small fill-in dialog collects a value per unique placeholder and substitutes them into the composer; a plain prompt inserts directly as before. Client-only, no schema change (placeholders are plain text in the prompt body). Chosen a fill DIALOG over inline cursor-jump templating deliberately — it sidesteps textarea DOM-ref/selection machinery and is complete for any number of variables + fully unit-testable via pure `extractPlaceholders`/`fillPlaceholders`. A two-reviewer round hardened it: substitution is a SINGLE `String.replace(regex, callback)` pass over the original body (so a value that itself contains `{{x}}` is never re-expanded); the `/` menu is dismissed via `setDismissedFor(input)` when the dialog opens (no menu-behind-dialog glitch or cancel-reopen loop); the regex is ReDoS-safe (`{{([^{}]*?)}}`, no overlapping quantifiers); and the dialog field ids are INDEX-based, because a placeholder name can contain spaces (`{{target language}}`) which is invalid in a DOM id and would break `label[for]`. Verified: 8 pure templating cases (unique/ordered extraction, dedupe, empty `{{}}` ignored, substitution, duplicate-fill, unfilled→empty, no-double-expansion), web build/lint/tsc clean; no api/schema touched.
- Hardened `apps/api/scripts/rls-test.sh`'s readiness wait: it now also confirms the published Postgres port is reachable from the **host** (bash `/dev/tcp`), not just that `pg_isready` succeeds inside the container — under WSL2/Docker the host port-forward can lag the container's internal readiness, which previously let the migration step connect too early and hit `CONNECT_TIMEOUT`.

# 2026-07-04
Expand Down
6 changes: 3 additions & 3 deletions apps/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ NestJS 11 backend: API + services, and owner of the database schema/migrations.

## Structure

- `src/` — one directory per feature, each a NestJS module (`chats/`, `runs/`, `compaction/`, `titles/`, `queue/`, `models/`, `auth/`, `users/`, `db/`); a feature another feature consumes exports its service from its own module, never re-provided elsewhere. Boundary rules: `queue/` is consumed ONLY by `runs/` (chats dispatches runs via `RunDispatchService` and never sees queue names/payloads); `runs/` hosts the whole execution domain (executor, worker consumers, dispatch, stream bridge — `RunWorkerModule` is what the dedicated worker entrypoint (#116) will boot); `db/DbModule` is the single global `TenantDbService` provider
- `src/db/` — `schema/` (`auth.ts`, `chats.ts`), `migrations/` (+ `meta/` journal), `migrate.ts`
- `src/` — one directory per feature, each a NestJS module (`chats/`, `runs/`, `compaction/`, `titles/`, `queue/`, `models/`, `auth/`, `users/`, `prompts/`, `db/`); a feature another feature consumes exports its service from its own module, never re-provided elsewhere. Boundary rules: `queue/` is consumed ONLY by `runs/` (chats dispatches runs via `RunDispatchService` and never sees queue names/payloads); `runs/` hosts the whole execution domain (executor, worker consumers, dispatch, stream bridge — `RunWorkerModule` is what the dedicated worker entrypoint (#116) will boot); `db/DbModule` is the single global `TenantDbService` provider
- `src/db/` — `schema/` (`auth.ts`, `chats.ts`, `prompts.ts`), `migrations/` (+ `meta/` journal), `migrate.ts`
- `src/main.ts`, `src/app.module.ts`

## Commands
Expand Down Expand Up @@ -58,4 +58,4 @@ Migrations run as a **non-superuser `app` role that owns the schema** (provision

- `apps/api/src/db` is the **sole** schema; `apps/web` owns no database.
- Linting is oxlint with type-aware rules (`.oxlintrc.json`, `options.typeAware`) running on **tsgo** (TypeScript 7). tsgo rejects `baseUrl`, so `tsconfig.json` must not reintroduce it, and global test/node types are declared explicitly via `"types": ["node", "jest"]` (tsgo does not auto-include `@types/*` under pnpm the way tsc does). Formatting is prettier (`pnpm format`), checked in CI via the root `format:check` — it is no longer an ESLint rule.
- Migrations are `drizzle-kit`-generated (`0005`+). Hand-authored exceptions: `0004` (the PoC → multi-tenant transition — drizzle-kit's interactive column-rename can't be driven non-interactively; `FORCE ROW LEVEL SECURITY` is hand-maintained here too, Drizzle can't express it), `0006` (the sessions hashing migration carries a manual `DELETE FROM sessions` — raw tokens can't be carried into the hashed-at-rest model), `0010` (the nullable-title migration carries a manual `UPDATE` backfilling old default-literal titles to NULL, and drops a spurious generated DROP/CREATE of the unchanged `sessions_user_created_idx`), `0011` (the durable-runs migration hand-appends `FORCE ROW LEVEL SECURITY` for `runs`/`run_events` — Drizzle emits ENABLE only — and hand-reorders the composite-key unique indexes before the FKs that reference them), `0012` (the single-flight migration carries a manual `UPDATE` cancelling all but the newest non-terminal run per chat — the partial unique index cannot be created over duplicates — plus matching `run.cancelled` events, applied inside a NO FORCE RLS window since migrations run as the owning role), and `0013` (the `in_reply_to` reply-integrity trigger, #73 — Drizzle can't express triggers). `drizzle-kit check` passes for all. Re-add the manual steps if you ever regenerate these.
- Migrations are `drizzle-kit`-generated (`0005`+). Hand-authored exceptions: `0004` (the PoC → multi-tenant transition — drizzle-kit's interactive column-rename can't be driven non-interactively; `FORCE ROW LEVEL SECURITY` is hand-maintained here too, Drizzle can't express it), `0006` (the sessions hashing migration carries a manual `DELETE FROM sessions` — raw tokens can't be carried into the hashed-at-rest model), `0010` (the nullable-title migration carries a manual `UPDATE` backfilling old default-literal titles to NULL, and drops a spurious generated DROP/CREATE of the unchanged `sessions_user_created_idx`), `0011` (the durable-runs migration hand-appends `FORCE ROW LEVEL SECURITY` for `runs`/`run_events` — Drizzle emits ENABLE only — and hand-reorders the composite-key unique indexes before the FKs that reference them), `0012` (the single-flight migration carries a manual `UPDATE` cancelling all but the newest non-terminal run per chat — the partial unique index cannot be created over duplicates — plus matching `run.cancelled` events, applied inside a NO FORCE RLS window since migrations run as the owning role), `0013` (the `in_reply_to` reply-integrity trigger, #73 — Drizzle can't express triggers), and `0015` (the prompt-library migration hand-appends `FORCE ROW LEVEL SECURITY` for `prompts`, same pattern as `0004`/`0011`). `drizzle-kit check` passes for all. Re-add the manual steps if you ever regenerate these.
238 changes: 238 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,178 @@
"runs"
]
}
},
"/api/v1/me/prompts": {
"get": {
"operationId": "MePromptsController_list",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptResponse"
}
}
}
}
},
"401": {
"description": ""
}
},
"security": [
{
"cookie": []
},
{
"bearer": []
}
],
"tags": [
"me"
]
},
"post": {
"operationId": "MePromptsController_create",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePromptDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PromptResponse"
}
}
}
},
"401": {
"description": ""
},
"409": {
"description": "Duplicate name, or at the per-user prompt cap"
}
},
"security": [
{
"cookie": []
},
{
"bearer": []
}
],
"tags": [
"me"
]
}
},
"/api/v1/me/prompts/{id}": {
"patch": {
"operationId": "MePromptsController_update",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePromptDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PromptResponse"
}
}
}
},
"401": {
"description": ""
},
"404": {
"description": "Unknown or cross-tenant prompt"
},
"409": {
"description": "Rename collides with an existing prompt"
}
},
"security": [
{
"cookie": []
},
{
"bearer": []
}
],
"tags": [
"me"
]
},
"delete": {
"operationId": "MePromptsController_remove",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"401": {
"description": ""
},
"404": {
"description": "Unknown or cross-tenant prompt"
}
},
"security": [
{
"cookie": []
},
{
"bearer": []
}
],
"tags": [
"me"
]
}
}
},
"info": {
Expand Down Expand Up @@ -1246,6 +1418,72 @@
"required": [
"status"
]
},
"PromptResponse": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"maxLength": 64
},
"content": {
"type": "string",
"maxLength": 8000
},
"createdAt": {
"format": "date-time",
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
"id",
"name",
"content",
"createdAt",
"updatedAt"
]
},
"CreatePromptDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 64,
"pattern": "^[A-Za-z0-9_-]+$"
},
"content": {
"type": "string",
"minLength": 1,
"maxLength": 8000
}
},
"required": [
"name",
"content"
]
},
"UpdatePromptDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 64,
"pattern": "^[A-Za-z0-9_-]+$"
},
"content": {
"type": "string",
"minLength": 1,
"maxLength": 8000
}
}
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions apps/api/scripts/rls-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ echo "▶ applying migrations as 'app' (so app owns every table)"
echo "▶ running RLS integration suite as 'app'"
( cd "$API_DIR" && TEST_DATABASE_URL="$APP_URL" pnpm exec jest chats-rls.integration --silent=false )

echo "▶ running prompts RLS integration suite as 'app'"
( cd "$API_DIR" && TEST_DATABASE_URL="$APP_URL" pnpm exec jest prompts-rls.integration --silent=false )

echo "▶ running queue integration suite (pg-boss on the same throwaway Postgres)"
( cd "$API_DIR" && TEST_DATABASE_URL="$APP_URL" pnpm exec jest queue.integration --silent=false )

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { ChatsModule } from './chats/chats.module';
import { DbModule } from './db/db.module';
import { PromptsModule } from './prompts/prompts.module';
import { RunsModule } from './runs/runs.module';
import { AuthModule } from './auth/auth.module';
import { SessionAuthGuard } from './auth/session-auth.guard';
Expand Down Expand Up @@ -43,6 +44,7 @@ import * as schema from './db/schema';
UsersModule,
DbModule,
ChatsModule,
PromptsModule,
RunsModule,
],
controllers: [AppController],
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/db/migrations/0015_wonderful_caretaker.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE "prompts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"content" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "prompts_name_slug" CHECK ("prompts"."name" ~ '^[A-Za-z0-9_-]{1,64}$'),
CONSTRAINT "prompts_content_len" CHECK (char_length("prompts"."content") BETWEEN 1 AND 8000)
);
--> statement-breakpoint
ALTER TABLE "prompts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "prompts" FORCE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "prompts" ADD CONSTRAINT "prompts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "prompts_user_name_idx" ON "prompts" USING btree ("user_id",lower("name"));--> statement-breakpoint
CREATE POLICY "prompts_owner" ON "prompts" AS PERMISSIVE FOR ALL TO public USING (user_id = current_setting('app.current_user_id', true));
Loading
Loading