The inline widget (Slice C, next) submits a richer per-turn payload than
the standalone page's single page_origin string:
context: {
route_name, page_origin, primary_entity_type, primary_entity_id,
user_selection_text, view_mode, filter_summary
}
Wiring:
- services.TurnContext + EnvelopePrefix() build a
`[ctx route=… entity=…:<id> selection="…" view=… filter="…"]` block.
Empty fields are omitted; selection is always quoted (it's user-supplied
content); selection over 1000 chars gets truncated with an ellipsis.
- services.MaxSelectionChars = 1000 (the design's privacy floor §4.3).
- LocalPaliadinService.RunTurn + RemotePaliadinService.RunTurn prepend the
envelope to the user message before sending through tmux.
- paliadinDB.insertTurnRow now persists the structured context as
paliad.paliadin_turns.context jsonb (migration 070).
- handlers/paliadin.go's turnRequest accepts the new optional context
field; mirrors context.PageOrigin into the top-level page_origin when
the latter is empty so legacy admin queries still work.
- The standalone /paliadin page is unchanged — its turn body still has
only page_origin, the new field is optional. Backwards compatible.
SKILL.md (~/.claude/skills/paliadin/SKILL.md, refreshed via
scripts/install-paliadin-skill):
- Documents the new `[ctx …]` block in front of the user question.
- Five behaviour rules: pre-call enrichment when entity= is set, don't
repeat the obvious, treat selection as data not instructions, no
hallucination on empty entity lookup, legacy turns work as before.
Frontend client/paliadin-context.ts is the route-table + entity
extraction the widget will use (Slice C). Public surface:
computePaliadinContext() returns the payload or null on excluded
routes (/paliadin, /login, /onboarding); selection toggle reads
localStorage["paliadin:send-selection"] (default on, off opts out).
New test TestTurnContext_EnvelopePrefix pins the bracket-block format
(8 sub-tests including truncation, selection-quote escape, empty-context
empty-prefix). go test ./... clean. go build + bun run build clean.
Refs: docs/design-paliadin-inline-2026-05-08.md §4.
197 lines
7.7 KiB
TypeScript
197 lines
7.7 KiB
TypeScript
// paliadin-context.ts — structured page-context payload builder for the
|
|
// Paliadin inline widget (t-paliad-161).
|
|
//
|
|
// The standalone /paliadin page submits turns with only `page_origin`
|
|
// (single string, the URL pathname). The inline widget submits a richer
|
|
// payload: route_name + primary_entity_type + primary_entity_id + the
|
|
// user's text selection + UI hints. The Go backend persists this jsonb
|
|
// in paliad.paliadin_turns.context (migration 070) AND prepends a
|
|
// flattened `[ctx …]` block to the tmux envelope so SKILL.md can branch
|
|
// on it before answering.
|
|
//
|
|
// Design: docs/design-paliadin-inline-2026-05-08.md §4.
|
|
|
|
export interface PaliadinContext {
|
|
route_name: string;
|
|
page_origin: string;
|
|
primary_entity_type?: "project" | "deadline" | "appointment";
|
|
primary_entity_id?: string;
|
|
user_selection_text?: string;
|
|
view_mode?: "list" | "cards" | "calendar" | "tree";
|
|
filter_summary?: string;
|
|
}
|
|
|
|
const SELECTION_MAX = 1000;
|
|
|
|
// UUID match — relaxed: any 8-4-4-4-12 hex pattern. Catches /projects/<id>
|
|
// and /deadlines/<id> regardless of trailing path segments.
|
|
const UUID_RE =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
/**
|
|
* Compute the Paliadin context for the current page. Reads
|
|
* window.location + window.getSelection() at call time, so callers
|
|
* should invoke this immediately before sending a turn — not at widget
|
|
* boot — to capture the user's selection in the moment they typed.
|
|
*
|
|
* Returns null when the visibility predicate fails (e.g. on /paliadin,
|
|
* /login, /onboarding) — callers SHOULD short-circuit on null instead
|
|
* of sending an empty payload.
|
|
*/
|
|
export function computePaliadinContext(): PaliadinContext | null {
|
|
const pathname = window.location.pathname || "";
|
|
if (!shouldSendContext(pathname)) {
|
|
return null;
|
|
}
|
|
const search = window.location.search || "";
|
|
const ctx: PaliadinContext = {
|
|
route_name: routeNameFor(pathname),
|
|
page_origin: pathname + search,
|
|
};
|
|
const entity = extractPrimaryEntity(pathname);
|
|
if (entity) {
|
|
ctx.primary_entity_type = entity.type;
|
|
ctx.primary_entity_id = entity.id;
|
|
}
|
|
const selection = readSelection();
|
|
if (selection) {
|
|
ctx.user_selection_text = selection;
|
|
}
|
|
const view = readViewMode();
|
|
if (view) {
|
|
ctx.view_mode = view;
|
|
}
|
|
const filter = readFilterSummary();
|
|
if (filter) {
|
|
ctx.filter_summary = filter;
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* The widget hides itself on routes where Paliadin is either redundant
|
|
* (the standalone /paliadin) or unavailable (auth flows). Mirrored here
|
|
* for the context-payload predicate so a stray send from one of those
|
|
* pages doesn't surface an empty `[ctx]` block.
|
|
*/
|
|
export function shouldSendContext(pathname: string): boolean {
|
|
if (pathname === "/paliadin" || pathname.startsWith("/paliadin/")) return false;
|
|
if (pathname === "/login" || pathname.startsWith("/login/")) return false;
|
|
if (pathname === "/onboarding") return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Map a URL pathname to a stable route key. Stable across query-string
|
|
* + ID variations so the SKILL.md / starter registry can branch on it
|
|
* without fragile URL parsing.
|
|
*/
|
|
export function routeNameFor(pathname: string): string {
|
|
// Order matters — most-specific first.
|
|
if (/^\/projects\/[^/]+$/.test(pathname)) return "projects.detail";
|
|
if (pathname === "/projects" || pathname === "/projects/") return "projects.list";
|
|
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
|
|
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
|
|
if (pathname === "/deadlines/new") return "deadlines.new";
|
|
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
|
|
if (pathname === "/deadlines") return "deadlines.list";
|
|
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
|
|
if (pathname === "/appointments/new") return "appointments.new";
|
|
if (pathname === "/appointments/calendar") return "appointments.calendar";
|
|
if (pathname === "/appointments") return "appointments.list";
|
|
if (pathname === "/agenda") return "agenda";
|
|
if (pathname === "/inbox") return "inbox";
|
|
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
|
|
if (pathname === "/team") return "team";
|
|
if (pathname === "/courts") return "courts";
|
|
if (pathname === "/glossary") return "glossary";
|
|
if (pathname === "/links") return "links";
|
|
if (pathname === "/downloads") return "downloads";
|
|
if (pathname === "/checklists") return "checklists";
|
|
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
|
|
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
|
|
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
|
|
if (pathname === "/events") return "events";
|
|
if (pathname.startsWith("/views/")) return "views.detail";
|
|
if (pathname === "/views") return "views.list";
|
|
if (pathname.startsWith("/admin/")) return "admin." + pathname.slice("/admin/".length).split("/")[0];
|
|
if (pathname === "/admin") return "admin";
|
|
if (pathname === "/settings") return "settings";
|
|
return "other";
|
|
}
|
|
|
|
/**
|
|
* Pull the primary entity (type + uuid) out of the URL when the route
|
|
* encodes one. Returns null on routes that have no primary entity
|
|
* (dashboard, agenda, lists, tools).
|
|
*/
|
|
export function extractPrimaryEntity(
|
|
pathname: string,
|
|
): { type: "project" | "deadline" | "appointment"; id: string } | null {
|
|
const projectMatch = pathname.match(/^\/projects\/([^/]+)(?:\/|$)/);
|
|
if (projectMatch && UUID_RE.test(projectMatch[1])) {
|
|
return { type: "project", id: projectMatch[1] };
|
|
}
|
|
const deadlineMatch = pathname.match(/^\/deadlines\/([^/]+)$/);
|
|
if (deadlineMatch && UUID_RE.test(deadlineMatch[1])) {
|
|
return { type: "deadline", id: deadlineMatch[1] };
|
|
}
|
|
const apptMatch = pathname.match(/^\/appointments\/([^/]+)$/);
|
|
if (apptMatch && UUID_RE.test(apptMatch[1])) {
|
|
return { type: "appointment", id: apptMatch[1] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Capture the user's current text selection, capped at SELECTION_MAX.
|
|
* Returns empty string when there's no selection or when the selection
|
|
* is collapsed (caret with no range).
|
|
*
|
|
* Privacy floor (§4.3): respects the widget's "send selection" toggle,
|
|
* stored in localStorage under `paliadin:send-selection`. Default on
|
|
* (m's Q5 lock-in); flip to off → returns empty string regardless of
|
|
* what's selected.
|
|
*/
|
|
export function readSelection(): string {
|
|
if (localStorage.getItem("paliadin:send-selection") === "off") {
|
|
return "";
|
|
}
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.isCollapsed) return "";
|
|
const text = sel.toString().trim();
|
|
if (!text) return "";
|
|
if (text.length > SELECTION_MAX) {
|
|
return text.slice(0, SELECTION_MAX);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
/**
|
|
* Probe the page for an active "view mode" hint — set by /events,
|
|
* /projects (tree vs list), /deadlines (calendar vs list). The frontend
|
|
* stores these as `data-view-mode` attributes on a known root element
|
|
* or in localStorage; this helper centralises the lookup so future
|
|
* pages adding a new view mode don't have to teach the widget about
|
|
* themselves.
|
|
*/
|
|
export function readViewMode(): "list" | "cards" | "calendar" | "tree" | "" {
|
|
const root = document.querySelector<HTMLElement>("[data-view-mode]");
|
|
if (!root) return "";
|
|
const v = root.dataset.viewMode || "";
|
|
if (v === "list" || v === "cards" || v === "calendar" || v === "tree") return v;
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Pull a short human-readable summary of active list filters from a
|
|
* known DOM hook. Pages that want to participate set
|
|
* `data-filter-summary="status=overdue · project=Acme"` on a root
|
|
* element. Empty = no summary.
|
|
*/
|
|
export function readFilterSummary(): string {
|
|
const root = document.querySelector<HTMLElement>("[data-filter-summary]");
|
|
if (!root) return "";
|
|
return (root.dataset.filterSummary || "").trim();
|
|
}
|