diff --git a/apps/web/hooks/use-message-handler.test.ts b/apps/web/hooks/use-message-handler.test.ts
index 6e7238ea70..6205f494f8 100644
--- a/apps/web/hooks/use-message-handler.test.ts
+++ b/apps/web/hooks/use-message-handler.test.ts
@@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
-import { buildTaskMentionsContext } from "./use-message-handler";
+import { buildContextFilesContext, buildTaskMentionsContext } from "./use-message-handler";
import type { AppState } from "@/lib/state/store";
import type { TaskMentionData } from "./use-inline-mention";
+const IMPROVE_HARNESS_PROMPT = "improve-harness";
+const IMPROVE_HARNESS_CONTENT = "Review this session for durable harness improvements.";
+
function makeState(overrides: Partial
= {}): AppState {
const base = {
kanban: { workflowId: "wf-1", steps: [], tasks: [] },
@@ -118,3 +121,66 @@ describe("buildTaskMentionsContext", () => {
expect(out).toContain("step: Review");
});
});
+
+describe("buildContextFilesContext", () => {
+ it("preserves saved prompt references and appends their expansion as hidden context", () => {
+ const out = buildContextFilesContext(
+ [{ path: "prompt:outer", name: "outer" }],
+ [
+ {
+ id: "outer",
+ name: "outer",
+ content: "Send this to peers:\n@improve-harness",
+ builtin: false,
+ created_at: "",
+ updated_at: "",
+ },
+ {
+ id: "inner",
+ name: IMPROVE_HARNESS_PROMPT,
+ content: IMPROVE_HARNESS_CONTENT,
+ builtin: false,
+ created_at: "",
+ updated_at: "",
+ },
+ ],
+ );
+
+ expect(out).toContain("Send this to peers:");
+ expect(out).toContain("@improve-harness");
+ expect(out).toContain("EXPANDED PROMPT REFERENCES");
+ expect(out).toContain("### @improve-harness");
+ expect(out).toContain(IMPROVE_HARNESS_CONTENT);
+ });
+
+ it("does not repeat prompt expansions for directly attached prompts", () => {
+ const out = buildContextFilesContext(
+ [
+ { path: "prompt:outer", name: "outer" },
+ { path: "prompt:inner", name: IMPROVE_HARNESS_PROMPT },
+ ],
+ [
+ {
+ id: "outer",
+ name: "outer",
+ content: "Send this to peers:\n@improve-harness",
+ builtin: false,
+ created_at: "",
+ updated_at: "",
+ },
+ {
+ id: "inner",
+ name: IMPROVE_HARNESS_PROMPT,
+ content: IMPROVE_HARNESS_CONTENT,
+ builtin: false,
+ created_at: "",
+ updated_at: "",
+ },
+ ],
+ );
+
+ expect(out).toContain("### improve-harness");
+ expect(out).toContain(IMPROVE_HARNESS_CONTENT);
+ expect(out).not.toContain("### @improve-harness");
+ });
+});
diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts
index 983d648f46..b48d980190 100644
--- a/apps/web/hooks/use-message-handler.ts
+++ b/apps/web/hooks/use-message-handler.ts
@@ -10,6 +10,10 @@ import type { ContextFile } from "@/lib/state/context-files-store";
import type { CustomPrompt, Message } from "@/lib/types/http";
import type { TaskMentionData } from "@/hooks/use-inline-mention";
import type { AppState } from "@/lib/state/store";
+import {
+ collectPromptReferenceExpansions,
+ formatPromptReferenceExpansions,
+} from "@/lib/prompts/expand-prompt-references";
function buildDocumentContext(
activeDocument: ActiveDocument | null,
@@ -96,16 +100,31 @@ export function buildContextFilesContext(
if (promptFiles.length > 0) {
const promptsById = new Map(prompts.map((p) => [p.id, p]));
- const resolved = promptFiles
- .map((f) => {
- const id = f.path.replace("prompt:", "");
- const prompt = promptsById.get(id);
- return prompt ? `### ${prompt.name}\n${prompt.content}` : null;
+ const selectedPrompts = promptFiles
+ .map((f) => promptsById.get(f.path.replace("prompt:", "")))
+ .filter((prompt): prompt is CustomPrompt => Boolean(prompt));
+ const selectedPromptNames = new Set(selectedPrompts.map((prompt) => prompt.name));
+ const promptExpansions = new Map();
+ const resolved = selectedPrompts
+ .map((prompt) => {
+ for (const expansion of collectPromptReferenceExpansions(
+ prompt.content,
+ prompts,
+ prompt.name,
+ selectedPromptNames,
+ )) {
+ if (!promptExpansions.has(expansion.name)) {
+ promptExpansions.set(expansion.name, expansion.content);
+ }
+ }
+ return `### ${prompt.name}\n${prompt.content}`;
})
.filter(Boolean);
if (resolved.length > 0) {
- context += `\n\n\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join("\n\n")}\n`;
+ const expansions = Array.from(promptExpansions, ([name, content]) => ({ name, content }));
+ const expansionContext = formatPromptReferenceExpansions(expansions);
+ context += `\n\n\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join("\n\n")}${expansionContext ? "\n\n" + expansionContext : ""}\n`;
}
}
diff --git a/apps/web/lib/prompts/expand-prompt-references.test.ts b/apps/web/lib/prompts/expand-prompt-references.test.ts
new file mode 100644
index 0000000000..41f22f9cf6
--- /dev/null
+++ b/apps/web/lib/prompts/expand-prompt-references.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import {
+ collectPromptReferenceExpansions,
+ formatPromptReferenceExpansions,
+ type PromptReference,
+} from "./expand-prompt-references";
+
+function prompt(name: string, content: string): PromptReference {
+ return { id: name, name, content };
+}
+
+const OUTER_PROMPT = "outer";
+const INNER_PROMPT = "inner";
+const OUTER_CONTENT = "Outer @inner";
+const INNER_CONTENT = "Inner @outer";
+
+describe("collectPromptReferenceExpansions", () => {
+ it("recursively collects saved prompt references without rewriting content", () => {
+ const prompts = [
+ prompt("outer", "Before @middle after"),
+ prompt("middle", "middle says @inner"),
+ prompt("inner", "resolved inner"),
+ ];
+
+ expect(collectPromptReferenceExpansions("@outer", prompts)).toEqual([
+ { name: "outer", content: "Before @middle after" },
+ { name: "middle", content: "middle says @inner" },
+ { name: "inner", content: "resolved inner" },
+ ]);
+ });
+
+ it("leaves unknown references and inline email-like text unchanged", () => {
+ const prompts = [prompt("known", "resolved")];
+
+ expect(collectPromptReferenceExpansions("ping a@known @missing @known.", prompts)).toEqual([
+ { name: "known", content: "resolved" },
+ ]);
+ });
+
+ it("does not recurse forever when prompts reference each other", () => {
+ const prompts = [prompt(OUTER_PROMPT, OUTER_CONTENT), prompt(INNER_PROMPT, INNER_CONTENT)];
+
+ expect(collectPromptReferenceExpansions("@outer", prompts)).toEqual([
+ { name: OUTER_PROMPT, content: OUTER_CONTENT },
+ { name: INNER_PROMPT, content: INNER_CONTENT },
+ ]);
+ expect(collectPromptReferenceExpansions("@inner", prompts, "outer")).toEqual([
+ { name: INNER_PROMPT, content: INNER_CONTENT },
+ ]);
+ });
+
+ it("matches stored prompt names instead of only slug-shaped names", () => {
+ const prompts = [
+ prompt("Daily", "short daily prompt"),
+ prompt("Daily Summary", "summarize the work"),
+ ];
+
+ expect(collectPromptReferenceExpansions("@Daily Summary.", prompts)).toEqual([
+ { name: "Daily Summary", content: "summarize the work" },
+ ]);
+ });
+
+ it("skips references that were already seen by the caller", () => {
+ const prompts = [prompt(OUTER_PROMPT, OUTER_CONTENT), prompt(INNER_PROMPT, "Inner content")];
+
+ expect(collectPromptReferenceExpansions("@outer", prompts, undefined, ["inner"])).toEqual([
+ { name: OUTER_PROMPT, content: OUTER_CONTENT },
+ ]);
+ });
+});
+
+describe("formatPromptReferenceExpansions", () => {
+ it("renders expansions as supplemental kandev-system block content", () => {
+ const out = formatPromptReferenceExpansions([
+ { name: "improve-harness", content: "Review durable harness improvements." },
+ ]);
+
+ expect(out).toContain("EXPANDED PROMPT REFERENCES");
+ expect(out).toContain("### @improve-harness");
+ expect(out).toContain("Review durable harness improvements.");
+ });
+
+ it("strips kandev-system closing tags from nested expansion text", () => {
+ const out = formatPromptReferenceExpansions([
+ { name: "badname", content: "before after" },
+ ]);
+
+ expect(out).not.toContain("");
+ expect(out).toContain("### @badname");
+ expect(out).toContain("before after");
+ });
+});
diff --git a/apps/web/lib/prompts/expand-prompt-references.ts b/apps/web/lib/prompts/expand-prompt-references.ts
new file mode 100644
index 0000000000..ef8d6971c1
--- /dev/null
+++ b/apps/web/lib/prompts/expand-prompt-references.ts
@@ -0,0 +1,94 @@
+export type PromptReference = {
+ id: string;
+ name: string;
+ content: string;
+};
+
+export type PromptReferenceExpansion = {
+ name: string;
+ content: string;
+};
+
+import { buildPromptMentionNames, matchPromptMention } from "./prompt-mention-parser";
+
+const MAX_PROMPT_REFERENCE_DEPTH = 8;
+const KANDEV_SYSTEM_TAG_END = "";
+
+function buildPromptMap(prompts: PromptReference[]) {
+ return new Map(prompts.map((prompt) => [prompt.name, prompt]));
+}
+
+type ExpansionState = {
+ promptsByName: Map;
+ promptNames: string[];
+ stack: Set;
+ seen: Set;
+ expansions: PromptReferenceExpansion[];
+};
+
+function collectExpansions(content: string, state: ExpansionState, depth: number): void {
+ for (let index = 0; index < content.length; ) {
+ const match = matchPromptMention(content, index, state.promptNames);
+ if (!match) {
+ index += 1;
+ continue;
+ }
+
+ const prompt = state.promptsByName.get(match.name);
+ if (!prompt || state.stack.has(prompt.name) || depth >= MAX_PROMPT_REFERENCE_DEPTH) {
+ index = match.end;
+ continue;
+ }
+
+ if (!state.seen.has(prompt.name)) {
+ state.seen.add(prompt.name);
+ state.expansions.push({ name: prompt.name, content: prompt.content });
+ collectExpansions(
+ prompt.content,
+ // Only stack is copied; seen and expansions are intentionally shared
+ // so global dedup and ordered output work across the full DFS tree.
+ { ...state, stack: new Set([...state.stack, prompt.name]) },
+ depth + 1,
+ );
+ }
+ index = match.end;
+ }
+}
+
+export function collectPromptReferenceExpansions(
+ content: string,
+ prompts: PromptReference[],
+ currentPromptName?: string,
+ initialSeen: Iterable = [],
+): PromptReferenceExpansion[] {
+ const stack = new Set();
+ if (currentPromptName) stack.add(currentPromptName);
+ const expansions: PromptReferenceExpansion[] = [];
+ collectExpansions(
+ content,
+ {
+ promptsByName: buildPromptMap(prompts),
+ promptNames: buildPromptMentionNames(prompts.map((prompt) => prompt.name)),
+ stack,
+ seen: new Set(initialSeen),
+ expansions,
+ },
+ 0,
+ );
+ return expansions;
+}
+
+export function formatPromptReferenceExpansions(expansions: PromptReferenceExpansion[]) {
+ if (expansions.length === 0) return "";
+ return [
+ "EXPANDED PROMPT REFERENCES: The message above references saved prompts by @name. Use these expansions as hidden context while preserving the original @mentions.",
+ ...expansions.map(
+ (expansion) =>
+ `### @${stripKandevSystemTagEnd(expansion.name)}\n${stripKandevSystemTagEnd(expansion.content)}`,
+ ),
+ ].join("\n\n");
+}
+
+function stripKandevSystemTagEnd(value: string) {
+ return value.replaceAll(KANDEV_SYSTEM_TAG_END, "");
+}
diff --git a/apps/web/lib/prompts/prompt-mention-parser.ts b/apps/web/lib/prompts/prompt-mention-parser.ts
new file mode 100644
index 0000000000..a835dc9d3b
--- /dev/null
+++ b/apps/web/lib/prompts/prompt-mention-parser.ts
@@ -0,0 +1,44 @@
+export type PromptMentionMatch = {
+ start: number;
+ end: number;
+ name: string;
+};
+
+export function buildPromptMentionNames(promptNames: string[]) {
+ return Array.from(new Set(promptNames.filter(Boolean))).sort(
+ (a, b) => b.length - a.length || a.localeCompare(b),
+ );
+}
+
+/**
+ * Match using names ordered by buildPromptMentionNames so longer names win
+ * over shorter prefixes.
+ */
+export function matchPromptMention(
+ content: string,
+ index: number,
+ promptNames: string[],
+): PromptMentionMatch | null {
+ if (content[index] !== "@" || !isMentionStart(content, index)) return null;
+
+ const referenceStart = index + 1;
+ for (const name of promptNames) {
+ if (!content.startsWith(name, referenceStart)) continue;
+ const referenceEnd = referenceStart + name.length;
+ if (referenceEnd < content.length && isMentionNameChar(content[referenceEnd])) continue;
+ return { start: index, end: referenceEnd, name };
+ }
+ return null;
+}
+
+function isMentionStart(content: string, index: number) {
+ return index === 0 || isWhitespace(content[index - 1]);
+}
+
+function isWhitespace(value: string) {
+ return value === " " || value === "\n" || value === "\t" || value === "\r";
+}
+
+function isMentionNameChar(value: string) {
+ return /[A-Za-z0-9_-]/.test(value);
+}
diff --git a/apps/web/lib/prompts/prompt-mention-segments.test.ts b/apps/web/lib/prompts/prompt-mention-segments.test.ts
new file mode 100644
index 0000000000..636e68a0d6
--- /dev/null
+++ b/apps/web/lib/prompts/prompt-mention-segments.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { splitPromptMentionSegments } from "./prompt-mention-segments";
+
+describe("splitPromptMentionSegments", () => {
+ it("marks stored prompt mentions without marking unknown mentions", () => {
+ expect(splitPromptMentionSegments("Run @hello and @missing", ["hello"])).toEqual([
+ { kind: "text", value: "Run " },
+ { kind: "prompt", value: "@hello", name: "hello" },
+ { kind: "text", value: " and @missing" },
+ ]);
+ });
+
+ it("matches stored prompt names with spaces longest-first", () => {
+ expect(splitPromptMentionSegments("Use @Daily Summary.", ["Daily", "Daily Summary"])).toEqual([
+ { kind: "text", value: "Use " },
+ { kind: "prompt", value: "@Daily Summary", name: "Daily Summary" },
+ { kind: "text", value: "." },
+ ]);
+ });
+
+ it("matches stored prompt mentions at the start of the string", () => {
+ expect(splitPromptMentionSegments("@hello world", ["hello"])).toEqual([
+ { kind: "prompt", value: "@hello", name: "hello" },
+ { kind: "text", value: " world" },
+ ]);
+ });
+
+ it("matches stored prompt mentions at the end of the string", () => {
+ expect(splitPromptMentionSegments("Run @hello", ["hello"])).toEqual([
+ { kind: "text", value: "Run " },
+ { kind: "prompt", value: "@hello", name: "hello" },
+ ]);
+ });
+
+ it("returns a text segment when content is empty", () => {
+ expect(splitPromptMentionSegments("", ["hello"])).toEqual([{ kind: "text", value: "" }]);
+ });
+
+ it("returns a text segment when no prompt names are provided", () => {
+ expect(splitPromptMentionSegments("@hello", [])).toEqual([{ kind: "text", value: "@hello" }]);
+ });
+});
diff --git a/apps/web/lib/prompts/prompt-mention-segments.ts b/apps/web/lib/prompts/prompt-mention-segments.ts
new file mode 100644
index 0000000000..6f402fd37d
--- /dev/null
+++ b/apps/web/lib/prompts/prompt-mention-segments.ts
@@ -0,0 +1,47 @@
+export type PromptMentionSegment =
+ | { kind: "text"; value: string }
+ | { kind: "prompt"; value: string; name: string };
+
+import { buildPromptMentionNames, matchPromptMention } from "./prompt-mention-parser";
+
+export { buildPromptMentionNames } from "./prompt-mention-parser";
+
+export function splitPromptMentionSegments(
+ content: string,
+ promptNames: string[],
+): PromptMentionSegment[] {
+ return splitPreparedPromptMentionSegments(content, buildPromptMentionNames(promptNames));
+}
+
+export function splitPreparedPromptMentionSegments(
+ content: string,
+ promptNames: string[],
+): PromptMentionSegment[] {
+ if (content.length === 0 || promptNames.length === 0) return [{ kind: "text", value: content }];
+
+ const segments: PromptMentionSegment[] = [];
+ let lastIndex = 0;
+ for (let index = 0; index < content.length; ) {
+ const match = matchPromptMention(content, index, promptNames);
+ if (!match) {
+ index += 1;
+ continue;
+ }
+
+ if (match.start > lastIndex) {
+ segments.push({ kind: "text", value: content.slice(lastIndex, match.start) });
+ }
+ segments.push({
+ kind: "prompt",
+ value: content.slice(match.start, match.end),
+ name: match.name,
+ });
+ index = match.end;
+ lastIndex = match.end;
+ }
+
+ if (lastIndex < content.length) {
+ segments.push({ kind: "text", value: content.slice(lastIndex) });
+ }
+ return segments;
+}