1
17import { z } from "zod";
18
19
20
21
22
23
24export function renderNumberedSource(n: number, label: string, text: string, cap = 1200): string {
25 return `[${n}] ${label}\n${text.slice(0, cap)}`;
26}
27
28
29export function renderSection(header: string, text: string, cap = 12000): string {
30 return `${header}\n${text.slice(0, cap)}`;
31}
32
33
34export function renderCandidate(id: string, title: string, text: string, cap = 1200): string {
35 return `[${id}] ${title}\n${text.slice(0, cap)}`;
36}
37
38interface ToolResult<T> {
39 ok: true;
40 value: T;
41
42 dropped: string[];
43}
44interface ToolError {
45 ok: false;
46 feedback: string;
47}
48
49
50
51
52
53export const EXTRACT_ACTIONS_SYSTEM_PROMPT = `You extract action items from meeting notes.
54Rules:
55- An action item is a concrete task someone should do (an owner mention is optional context).
56- "title" is a short imperative summary, max 200 characters, no leading bullet or "action" word.
57- "dueDate" is "YYYY-MM-DD" and ONLY when the note states an explicit date; never invent a date.
58- Extract at most 10 items, in note order.
59- Use ONLY the provided notes. If there are no action items, return an empty list.
60Answer by calling the extractActionItems tool.`;
61
62export const EXTRACT_ACTION_ITEMS_TOOL = {
63 name: "extractActionItems",
64 description: "Return the action items found in the meeting notes.",
65 parameters: {
66 type: "object",
67 properties: {
68 actions: {
69 type: "array",
70 description: "The extracted action items, in note order (max 10).",
71 items: {
72 type: "object",
73 properties: {
74 title: { type: "string", description: "Short imperative task summary (max 200 chars)." },
75 dueDate: { type: "string", description: "Due date YYYY-MM-DD, only if stated in the notes." },
76 },
77 required: ["title"],
78 },
79 },
80 },
81 required: ["actions"],
82 },
83} as const;
84
85export interface ExtractedAction {
86 title: string;
87 dueDate?: string;
88}
89
90const actionItemSchema = z.object({
91 title: z.string().trim().min(1).max(200),
92 dueDate: z
93 .string()
94 .regex(/^\d{4}-\d{2}-\d{2}$/, "dueDate must be YYYY-MM-DD")
95 .optional(),
96});
97
98export function validateExtractActions(raw: unknown): (ToolResult<ExtractedAction[]> & { value: ExtractedAction[] }) | ToolError {
99 const parsed = z
100 .object({ actions: z.array(actionItemSchema).max(10) })
101 .safeParse(raw);
102 if (!parsed.success) {
103 return { ok: false, feedback: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") };
104 }
105 return { ok: true, value: parsed.data.actions, dropped: [] };
106}
107
108export function renderExtractPrompt(text: string): string {
109 return renderSection("Source (meeting notes):", text);
110}
111
112
113
114
115
116export const GENERATE_FAQS_SYSTEM_PROMPT = `You generate FAQs from the numbered workspace sources.
117Rules:
118- Every answer must be grounded in the sources. End each answer with the source marker, e.g. "(per [1])".
119- Do NOT invent facts, references, or source numbers that are not in the list.
120- At most 15 FAQs. Questions are short; answers are 1-3 sentences.
121- If the sources contain nothing FAQ-worthy, return an empty list.
122Answer by calling the generateFaqs tool.`;
123
124export const GENERATE_FAQS_TOOL = {
125 name: "generateFaqs",
126 description: "Return the FAQ draft generated from the sources.",
127 parameters: {
128 type: "object",
129 properties: {
130 title: { type: "string", description: "Title of the FAQ page (max 200 chars)." },
131 faqs: {
132 type: "array",
133 description: "The FAQ entries, max 15.",
134 items: {
135 type: "object",
136 properties: {
137 question: { type: "string", description: "The question (max 300 chars)." },
138 answer: { type: "string", description: "The answer, ending with the source marker like (per [1])." },
139 },
140 required: ["question", "answer"],
141 },
142 },
143 },
144 required: ["title", "faqs"],
145 },
146} as const;
147
148export interface FaqEntry {
149 question: string;
150 answer: string;
151}
152
153export function validateGenerateFaqs(raw: unknown): (ToolResult<{ title: string; faqs: FaqEntry[] }>) | ToolError {
154 const parsed = z
155 .object({
156 title: z.string().trim().min(1).max(200),
157 faqs: z
158 .array(
159 z.object({
160 question: z.string().trim().min(1).max(300),
161 answer: z.string().trim().min(1).max(2000),
162 })
163 )
164 .max(15),
165 })
166 .safeParse(raw);
167 if (!parsed.success) {
168 return { ok: false, feedback: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") };
169 }
170 return { ok: true, value: { title: parsed.data.title, faqs: parsed.data.faqs }, dropped: [] };
171}
172
173export function renderFaqsPrompt(sources: Array<{ label: string; text: string }>, n: number): string {
174 return sources.map((s, i) => renderNumberedSource(i + 1, s.label, s.text)).join("\n\n");
175}
176
177
178
179
180
181export const FIND_DUPLICATES_SYSTEM_PROMPT = `You identify duplicate or near-duplicate documentation pages.
182You are given candidate pages, each as "[<id>] <title>" followed by a text excerpt.
183Rules:
184- A group is 2-6 candidate pages that cover the same topic with substantially overlapping content.
185- Cite ONLY page ids from the provided list. Never invent an id.
186- "similarity" is 0-1 (your confidence). "reason" is one sentence.
187- At most 20 groups. If there are no duplicates, return an empty list.
188Answer by calling the findDuplicateGroups tool.`;
189
190export const FIND_DUPLICATE_GROUPS_TOOL = {
191 name: "findDuplicateGroups",
192 description: "Return groups of candidate pages that are likely duplicates.",
193 parameters: {
194 type: "object",
195 properties: {
196 groups: {
197 type: "array",
198 description: "Duplicate groups (max 20).",
199 items: {
200 type: "object",
201 properties: {
202 pageIds: {
203 type: "array",
204 description: "Candidate page ids in the group (2-6), from the provided list only.",
205 items: { type: "string" },
206 },
207 similarity: { type: "number", description: "Confidence 0-1." },
208 reason: { type: "string", description: "One-sentence reason." },
209 },
210 required: ["pageIds", "similarity", "reason"],
211 },
212 },
213 },
214 required: ["groups"],
215 },
216} as const;
217
218export interface DuplicateGroup {
219 pageIds: string[];
220 similarity: number;
221 reason: string;
222}
223
224export function validateDuplicateGroups(
225 raw: unknown,
226 knownPageIds: Set<string>,
227): (ToolResult<DuplicateGroup[]>) | ToolError {
228 const parsed = z
229 .object({
230 groups: z
231 .array(
232 z.object({
233 pageIds: z.array(z.string().min(1)).min(2).max(6),
234 similarity: z.number().min(0).max(1),
235 reason: z.string().trim().min(1).max(300),
236 })
237 )
238 .max(20),
239 })
240 .safeParse(raw);
241 if (!parsed.success) {
242 return { ok: false, feedback: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") };
243 }
244
245
246 const dropped = new Set<string>();
247 const groups: DuplicateGroup[] = [];
248 for (const g of parsed.data.groups) {
249 const kept = g.pageIds.filter((id) => {
250 if (knownPageIds.has(id)) return true;
251 dropped.add(id);
252 return false;
253 });
254 if (kept.length >= 2) groups.push({ pageIds: kept, similarity: g.similarity, reason: g.reason });
255 }
256 return { ok: true, value: groups, dropped: [...dropped] };
257}
258
259export function renderDuplicatesPrompt(candidates: Array<{ id: string; title: string; text: string }>): string {
260 return "Candidate pages:\n" + candidates.map((c) => renderCandidate(c.id, c.title, c.text)).join("\n\n");
261}
262
263
264
265
266
267export const FIND_STALE_SYSTEM_PROMPT = `You identify stale or outdated documentation pages.
268You are given candidate pages, each as "[<id>] <title> (last updated <date>)" followed by a text excerpt.
269Rules:
270- Flag a page when its content shows signs of being outdated (old dates, superseded info, obsolete steps).
271- Cite ONLY page ids from the provided list. Never invent an id.
272- "reason" is one sentence citing what looks stale.
273- At most 100 pages. If none look stale, return an empty list.
274Answer by calling the findStalePages tool.`;
275
276export const FIND_STALE_PAGES_TOOL = {
277 name: "findStalePages",
278 description: "Return the candidate pages that appear stale.",
279 parameters: {
280 type: "object",
281 properties: {
282 pages: {
283 type: "array",
284 description: "Stale pages (max 100).",
285 items: {
286 type: "object",
287 properties: {
288 pageId: { type: "string", description: "Candidate page id, from the provided list only." },
289 reason: { type: "string", description: "One sentence citing what looks stale." },
290 },
291 required: ["pageId", "reason"],
292 },
293 },
294 },
295 required: ["pages"],
296 },
297} as const;
298
299export interface StalePage {
300 pageId: string;
301 reason: string;
302}
303
304export function validateStalePages(raw: unknown, knownPageIds: Set<string>): (ToolResult<StalePage[]>) | ToolError {
305 const parsed = z
306 .object({
307 pages: z
308 .array(
309 z.object({
310 pageId: z.string().min(1),
311 reason: z.string().trim().min(1).max(300),
312 })
313 )
314 .max(100),
315 })
316 .safeParse(raw);
317 if (!parsed.success) {
318 return { ok: false, feedback: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") };
319 }
320 const dropped = new Set<string>();
321 const pages: StalePage[] = [];
322 for (const p of parsed.data.pages) {
323 if (!knownPageIds.has(p.pageId)) {
324 dropped.add(p.pageId);
325 continue;
326 }
327 pages.push({ pageId: p.pageId, reason: p.reason });
328 }
329 return { ok: true, value: pages, dropped: [...dropped] };
330}
331
332export function renderStalePrompt(candidates: Array<{ id: string; title: string; text: string; updatedAt: string }>): string {
333 return (
334 "Candidate pages (not updated in the last days threshold):\n" +
335 candidates
336 .map((c) => renderCandidate(c.id, `${c.title} (last updated ${c.updatedAt.slice(0, 10)})`, c.text))
337 .join("\n\n")
338 );
339}
340
341
342
343
344
345
346
347
348
349
350
351
352export const SUGGEST_METADATA_SYSTEM_PROMPT = `You suggest improved metadata for a single page.
353Rules:
354- "title" (max 200 chars) and "description" (1-2 sentences, max 2000 chars).
355- Ground both in the page content; do not invent facts.
356- Omit a field when the current value is already fine. Provide at least one.
357- The suggestion is a PROPOSAL — nothing is written until the user applies it.
358Answer by calling the suggestPageMetadata tool.`;
359
360export const SUGGEST_PAGE_METADATA_TOOL = {
361 name: "suggestPageMetadata",
362 description: "Return the suggested title and/or description for the page.",
363 parameters: {
364 type: "object",
365 properties: {
366 title: { type: "string", description: "Suggested title (max 200 chars), if improved." },
367 description: { type: "string", description: "Suggested 1-2 sentence description (max 2000 chars), if improved." },
368 },
369 },
370} as const;
371
372export interface PageMetadataSuggestion {
373 title?: string;
374 description?: string;
375}
376
377export function validateMetadataSuggestion(raw: unknown): ToolResult<PageMetadataSuggestion> | ToolError {
378 const parsed = z
379 .object({
380 title: z.string().trim().min(1).max(200).optional(),
381 description: z.string().trim().min(1).max(2000).optional(),
382 })
383 .safeParse(raw);
384 if (!parsed.success) {
385 return { ok: false, feedback: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") };
386 }
387 const { title, description } = parsed.data;
388 if (!title && !description) {
389 return { ok: false, feedback: "Provide at least one of title or description." };
390 }
391 return { ok: true, value: { title, description }, dropped: [] };
392}
393
394export function renderMetadataPrompt(page: { title: string; text: string }): string {
395 return renderSection("Page title:", page.title) + "\n\n" + renderSection("Page content:", page.text, 8000);
396}
397
398
399export const ASSISTANT_CONV_PREFIX = "assistant:";
400