返回 oh-my-ppt
thinking-tools.ts
根目录 / src / main / thinking / thinking-tools.ts
1 import fs from 'fs'
2 import path from 'path'
3 import { tool, type StructuredToolInterface } from '@langchain/core/tools'
4 import { z } from 'zod'
5 import { writeContextMd, writeThinkingMd } from './workspace'
6 import { isValidTransition, VALID_TRANSITIONS } from './stage-manager'
7 import type { ThinkingStage } from '@shared/thinking'
8
9 const THINKING_STAGES = ['collect', 'outline', 'draft', 'refine', 'ready'] as const
10
11 export interface ThinkingWorkflowState {
12 contextUpdated: boolean
13 thinkingUpdated: boolean
14 thinkingStaged: boolean
15 contextUpdateCount: number
16 thinkingUpdateCount: number
17 requestedStage: ThinkingStage | null
18 }
19
20 export type StagedThinkingFinalizeResult =
21 | { status: 'none' }
22 | { status: 'incomplete'; reason: string; pageCount: number; expectedPageCount: number | null }
23 | { status: 'committed'; length: number; pageCount: number }
24 | { status: 'discarded'; reason: string; pageCount: number; expectedPageCount: number | null }
25
26 const pageRoleSchema = z.enum(['cover', 'section', 'content', 'case', 'comparison', 'data', 'summary'])
27
28 const contextDecisionSchema = z.union([
29 z.string(),
30 z.record(z.string(), z.coerce.string())
31 ])
32
33 const contextDocumentSchema = z.object({
34 topic: z.string().optional().describe('Presentation topic when known.'),
35 userIntent: z
36 .string()
37 .optional()
38 .describe('Short markdown summary of what the user wants and what has been learned.'),
39 confirmedDecisions: z
40 .array(contextDecisionSchema)
41 .optional()
42 .describe(
43 'Confirmed durable decisions. Each item may be a concise string or a flat key-value object. Omit to preserve existing decisions; pass [] to clear them. Do not include guesses.'
44 ),
45 openQuestions: z
46 .array(z.string())
47 .optional()
48 .describe(
49 'Only unresolved questions that still matter. Omit to preserve existing questions; pass [] to clear them.'
50 ),
51 sourceNotes: z
52 .array(z.string())
53 .optional()
54 .describe(
55 'Facts or observations from uploaded/source materials. Omit to preserve existing notes; pass [] to clear them.'
56 ),
57 latestDirection: z
58 .string()
59 .optional()
60 .describe('Latest user message or direction, summarized without tool chatter.'),
61 stage: z
62 .enum(THINKING_STAGES)
63 .optional()
64 .describe(
65 'Transition to this stage. Only set when the user explicitly requests or when requirements for the next stage are met.'
66 )
67 })
68
69 const thinkingDocumentSchema = z.object({
70 topic: z.string().optional(),
71 audience: z.string().optional(),
72 setting: z.string().optional(),
73 tone: z.string().optional(),
74 keyDecisions: z.array(z.string()).optional(),
75 openQuestions: z.array(z.string()).optional(),
76 style: z.string().optional(),
77 font: z
78 .union([z.string(), z.record(z.string(), z.unknown())])
79 .optional()
80 .describe('Font preference. Use "auto" or a FontSelection JSON object with mode/title/body.'),
81 pageCount: z.coerce.number().int().positive().optional(),
82 pageStart: z.coerce
83 .number()
84 .int()
85 .positive()
86 .optional()
87 .describe(
88 'When passing a page batch, this is the 1-based page number for the first item in pages. With pageStart, pages is merged into an in-memory draft and is not written to thinking.md until commit is true.'
89 ),
90 commit: z
91 .boolean()
92 .optional()
93 .describe(
94 'Set true on the final page batch to write the fully merged in-memory thinking document to thinking.md. For pageStart batches, omit/false means stage only.'
95 ),
96 pages: z
97 .array(
98 z.object({
99 title: z.string().min(1),
100 role: pageRoleSchema.describe('Page role in the narrative structure.'),
101 objective: z.string().min(1).describe('What this page must accomplish for the audience.'),
102 summary: z.string().min(1),
103 keyPoints: z.array(z.string().min(1)).min(1)
104 })
105 )
106 .optional()
107 .describe('Ordered slide/page plan. If pageStart is omitted, pages replaces all existing pages immediately. If pageStart is provided, pages is staged as a batch until commit is true.')
108 })
109
110 type ContextDocumentInput = z.infer<typeof contextDocumentSchema>
111 type ThinkingDocumentInput = z.infer<typeof thinkingDocumentSchema>
112
113 const THINKING_SECTION_ORDER = [
114 'Topic',
115 'Audience',
116 'Setting',
117 'Tone',
118 'Key Decisions',
119 'Open Questions',
120 'Style',
121 'Font',
122 'Page Count'
123 ]
124
125 function bulletList(items: string[] | undefined): string {
126 return (items || [])
127 .map((item) => item.trim())
128 .filter(Boolean)
129 .map((item) => (item.startsWith('- ') ? item : `- ${item}`))
130 .join('\n')
131 }
132
133 function contextDecisionList(items: ContextDocumentInput['confirmedDecisions']): string {
134 return (items || [])
135 .map((item) => {
136 if (typeof item === 'string') return item.trim()
137 return Object.entries(item)
138 .map(([key, value]) => `${key.trim()}: ${value.trim()}`)
139 .filter((item) => !item.endsWith(': '))
140 .join(';')
141 })
142 .filter(Boolean)
143 .map((item) => (item.startsWith('- ') ? item : `- ${item}`))
144 .join('\n')
145 }
146
147 function optionalSection(title: string, content: string | undefined): string {
148 const value = content?.trim()
149 return value ? `## ${title}\n${value}\n\n` : ''
150 }
151
152 function upsertSection(markdown: string, heading: string, content: string): string {
153 const normalizedContent = content.trim()
154 if (!normalizedContent) return markdown
155 const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
156 const sectionRegex = new RegExp(`^##\\s*${escaped}\\s*\\n[\\s\\S]*?(?=^##\\s+|(?![\\s\\S]))`, 'm')
157 const nextSection = `## ${heading}\n${normalizedContent}\n\n`
158 if (sectionRegex.test(markdown)) {
159 return markdown.replace(sectionRegex, nextSection.trimEnd() + '\n\n')
160 }
161
162 for (let index = THINKING_SECTION_ORDER.indexOf(heading) - 1; index >= 0; index -= 1) {
163 const previous = THINKING_SECTION_ORDER[index]
164 const previousRegex = new RegExp(
165 `^##\\s*${previous.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\n[\\s\\S]*?(?=^##\\s+|(?![\\s\\S]))`,
166 'm'
167 )
168 const match = markdown.match(previousRegex)
169 if (match?.[0]) {
170 const insertAt = (match.index || 0) + match[0].length
171 return `${markdown.slice(0, insertAt).trimEnd()}\n\n${nextSection}${markdown.slice(insertAt).trimStart()}`
172 }
173 }
174
175 const titleMatch = markdown.match(/^# .+$/m)
176 if (titleMatch) {
177 const insertAt = (titleMatch.index || 0) + titleMatch[0].length
178 return `${markdown.slice(0, insertAt).trimEnd()}\n\n${nextSection}${markdown.slice(insertAt).trimStart()}`
179 }
180 return `# Thinking Brief\n\n${nextSection}${markdown.trim()}`
181 }
182
183 function stripPageSections(markdown: string): string {
184 return markdown.replace(/\n*##\s*Page\s+\d+\s*:[\s\S]*$/m, '').trimEnd() + '\n'
185 }
186
187 type ThinkingPageInput = {
188 title: string
189 role: z.infer<typeof pageRoleSchema>
190 objective: string
191 summary: string
192 keyPoints: string[]
193 }
194
195 function buildPageSection(page: ThinkingPageInput, pageNumber: number): string {
196 const title = page.title.trim()
197 const role = page.role.trim()
198 const objective = page.objective.trim()
199 const summary = page.summary.trim()
200 const keyPoints = bulletList(page.keyPoints)
201
202 if (!title) {
203 throw new Error(`Page ${pageNumber} must have a real title.`)
204 }
205 if (!role) {
206 throw new Error(`Page ${pageNumber} must have a role.`)
207 }
208 if (!objective) {
209 throw new Error(`Page ${pageNumber} must have an objective.`)
210 }
211 if (!summary) {
212 throw new Error(`Page ${pageNumber} must have a non-empty summary. Do not write placeholder pages.`)
213 }
214 if (!keyPoints) {
215 throw new Error(`Page ${pageNumber} must include substantive keyPoints. Do not write placeholder pages.`)
216 }
217
218 return [
219 `## Page ${pageNumber}: ${title}`,
220 `- Role: ${role}`,
221 `- Objective: ${objective}`,
222 '',
223 summary,
224 '',
225 keyPoints,
226 ''
227 ].join('\n')
228 }
229
230 function buildPageSections(
231 pages: ThinkingPageInput[],
232 startPageNumber = 1
233 ): string {
234 return pages
235 .map((page, index) => buildPageSection(page, startPageNumber + index))
236 .join('\n')
237 .trimEnd()
238 }
239
240 function getPageSectionEntries(markdown: string): Array<{
241 pageNumber: number
242 content: string
243 }> {
244 const headingRegex = /^##\s*Page\s+(\d+)\s*:/gm
245 const headings = Array.from(markdown.matchAll(headingRegex))
246 return headings.flatMap((heading, index) => {
247 const pageNumber = Number.parseInt(heading[1], 10)
248 if (!Number.isFinite(pageNumber)) return []
249 const start = heading.index || 0
250 const next = headings[index + 1]
251 const end = typeof next?.index === 'number' ? next.index : markdown.length
252 return [{ pageNumber, content: markdown.slice(start, end).trimEnd() }]
253 })
254 }
255
256 function mergePageBatch(
257 markdown: string,
258 pages: ThinkingPageInput[],
259 pageStart: number
260 ): string {
261 const existingPages = new Map<number, string>()
262 for (const entry of getPageSectionEntries(markdown)) {
263 existingPages.set(entry.pageNumber, entry.content)
264 }
265 pages.forEach((page, index) => {
266 const pageNumber = pageStart + index
267 existingPages.set(pageNumber, buildPageSection(page, pageNumber).trimEnd())
268 })
269
270 const pageSections = Array.from(existingPages.entries())
271 .sort(([a], [b]) => a - b)
272 .map(([, content]) => content)
273 .join('\n\n')
274
275 return `${stripPageSections(markdown).trimEnd()}\n\n${pageSections}`.trimEnd()
276 }
277
278 function readMarkdownSection(markdown: string, heading: string): string {
279 const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
280 const inline = markdown.match(new RegExp(`^##\\s*${escaped}\\s*:\\s*(.+)`, 'm'))
281 if (inline?.[1]?.trim()) return inline[1].trim()
282 const block = markdown.match(
283 new RegExp(`^##\\s*${escaped}\\s*\\n([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, 'm')
284 )
285 return block?.[1]?.trim() || ''
286 }
287
288 function readDeclaredPageCount(markdown: string): number | null {
289 const raw = readMarkdownSection(markdown, 'Page Count')
290 const value = Number.parseInt(raw, 10)
291 return Number.isFinite(value) && value > 0 ? value : null
292 }
293
294 function hasCompletePageSection(pageSection: string): boolean {
295 const hasTitle = /^##\s*Page\s+\d+\s*:\s*\S+/m.test(pageSection)
296 const hasRole = /^-\s*Role:\s*\S+/mi.test(pageSection)
297 const hasObjective = /^-\s*Objective:\s*\S+/mi.test(pageSection)
298 const contentLines = pageSection
299 .split('\n')
300 .map((line) => line.trim())
301 .filter(Boolean)
302 .filter((line) => !/^##\s*Page\s+\d+\s*:/i.test(line))
303 .filter((line) => !/^-\s*(Role|Objective):/i.test(line))
304 const hasSummary = contentLines.some((line) => !line.startsWith('- '))
305 const hasKeyPoints = contentLines.some((line) => /^-\s+\S+/.test(line))
306 return hasTitle && hasRole && hasObjective && hasSummary && hasKeyPoints
307 }
308
309 function resolveStagedCompletion(markdown: string): {
310 complete: boolean
311 reason: string
312 pageCount: number
313 expectedPageCount: number | null
314 } {
315 const expectedPageCount = readDeclaredPageCount(markdown)
316 const pages = getPageSectionEntries(markdown)
317 if (!expectedPageCount) {
318 return {
319 complete: false,
320 reason: 'missing page count',
321 pageCount: pages.length,
322 expectedPageCount
323 }
324 }
325
326 const byNumber = new Map(pages.map((page) => [page.pageNumber, page.content]))
327 for (let pageNumber = 1; pageNumber <= expectedPageCount; pageNumber += 1) {
328 const section = byNumber.get(pageNumber)
329 if (!section) {
330 return {
331 complete: false,
332 reason: `missing page ${pageNumber}`,
333 pageCount: pages.length,
334 expectedPageCount
335 }
336 }
337 if (!hasCompletePageSection(section)) {
338 return {
339 complete: false,
340 reason: `incomplete page ${pageNumber}`,
341 pageCount: pages.length,
342 expectedPageCount
343 }
344 }
345 }
346
347 return {
348 complete: true,
349 reason: 'complete',
350 pageCount: pages.length,
351 expectedPageCount
352 }
353 }
354
355 async function readExistingThinkingMd(thinkingDir: string): Promise<string> {
356 const filePath = path.join(thinkingDir, 'thinking.md')
357 try {
358 if (fs.existsSync(filePath)) {
359 return fs.promises.readFile(filePath, 'utf-8')
360 }
361 } catch {
362 // Fall through to a fresh document.
363 }
364 return '# Thinking Brief\n'
365 }
366
367 function formatFontSection(font: ThinkingDocumentInput['font']): string | undefined {
368 if (typeof font === 'string') return font
369 if (font && typeof font === 'object') return JSON.stringify(font)
370 return undefined
371 }
372
373 function buildContextMd(args: {
374 stage: ThinkingStage
375 topic?: string
376 userIntent?: string
377 confirmedDecisions?: ContextDocumentInput['confirmedDecisions']
378 openQuestions?: string[]
379 latestDirection?: string
380 sourceNotes?: string[]
381 }): string {
382 const topic = args.topic?.trim()
383 const confirmedDecisions = contextDecisionList(args.confirmedDecisions)
384 const openQuestions = bulletList(args.openQuestions)
385 const sourceNotes = bulletList(args.sourceNotes)
386 const userIntent = args.userIntent?.trim() || (topic ? `- Topic: ${topic}` : '')
387
388 return [
389 `## Stage: ${args.stage}`,
390 '',
391 topic ? `## Topic\n${topic}\n` : '',
392 optionalSection('User Intent', userIntent),
393 optionalSection('Confirmed Decisions', confirmedDecisions),
394 optionalSection('Open Questions', openQuestions),
395 optionalSection('Source Notes', sourceNotes),
396 optionalSection('Latest Direction', args.latestDirection)
397 ]
398 .join('\n')
399 .trimEnd() + '\n'
400 }
401
402 function removeSection(markdown: string, heading: string): string {
403 const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
404 const sectionRegex = new RegExp(
405 `^##\\s*${escaped}\\s*\\n[\\s\\S]*?(?=^##\\s+|(?![\\s\\S]))`,
406 'm'
407 )
408 return markdown.replace(sectionRegex, '').replace(/\n{3,}/g, '\n\n').trimEnd()
409 }
410
411 function mergeContextMd(
412 baseMarkdown: string,
413 input: ContextDocumentInput,
414 currentStage: ThinkingStage
415 ): string {
416 let next = baseMarkdown.trim() || `## Stage: ${currentStage}`
417 if (/^## Stage:\s*\S+/m.test(next)) {
418 next = next.replace(/^## Stage:\s*\S+/m, `## Stage: ${currentStage}`)
419 } else {
420 next = `## Stage: ${currentStage}\n\n${next}`
421 }
422
423 const updates: Array<[string, string | undefined]> = [
424 ['Topic', input.topic],
425 ['User Intent', input.userIntent],
426 [
427 'Confirmed Decisions',
428 input.confirmedDecisions === undefined
429 ? undefined
430 : contextDecisionList(input.confirmedDecisions)
431 ],
432 [
433 'Open Questions',
434 input.openQuestions === undefined ? undefined : bulletList(input.openQuestions)
435 ],
436 [
437 'Source Notes',
438 input.sourceNotes === undefined ? undefined : bulletList(input.sourceNotes)
439 ],
440 ['Latest Direction', input.latestDirection]
441 ]
442
443 for (const [heading, content] of updates) {
444 if (content === undefined) continue
445 next = content.trim()
446 ? upsertSection(next, heading, content)
447 : removeSection(next, heading)
448 }
449
450 return next.trimEnd() + '\n'
451 }
452
453 async function mergeThinkingMdFromBase(
454 baseMarkdown: string,
455 input: ThinkingDocumentInput
456 ): Promise<string> {
457 let next = baseMarkdown.trim() || '# Thinking Brief'
458 const pages = input.pages
459 const pageStart = input.pageStart && input.pageStart > 0 ? input.pageStart : undefined
460 const isPageBatch = Boolean(pageStart)
461
462 const simpleSections: Array<[string, string | undefined]> = [
463 ['Topic', input.topic],
464 ['Audience', input.audience],
465 ['Setting', input.setting],
466 ['Tone', input.tone],
467 ['Key Decisions', bulletList(input.keyDecisions)],
468 ['Open Questions', bulletList(input.openQuestions)],
469 ['Style', input.style],
470 ['Font', formatFontSection(input.font)],
471 [
472 'Page Count',
473 input.pageCount ? String(input.pageCount) : !isPageBatch && pages?.length ? String(pages.length) : undefined
474 ]
475 ]
476
477 for (const [title, content] of simpleSections) {
478 if (content?.trim()) {
479 next = upsertSection(next, title, content)
480 }
481 }
482
483 if (Array.isArray(pages)) {
484 const pageSections = pageStart ? mergePageBatch(next, pages, pageStart) : buildPageSections(pages)
485 if (pageSections) {
486 next = pageStart ? pageSections : `${stripPageSections(next).trimEnd()}\n\n${pageSections}`
487 }
488 }
489
490 return next.trimEnd() + '\n'
491 }
492
493 async function mergeThinkingMd(thinkingDir: string, input: ThinkingDocumentInput): Promise<string> {
494 return mergeThinkingMdFromBase(await readExistingThinkingMd(thinkingDir), input)
495 }
496
497 export function createThinkingWorkflowTools(args: {
498 thinkingDir: string
499 currentStage: ThinkingStage
500 }): {
501 tools: StructuredToolInterface[]
502 state: ThinkingWorkflowState
503 finalizeStagedThinkingDocument: (options?: {
504 discardIncomplete?: boolean
505 }) => Promise<StagedThinkingFinalizeResult>
506 } {
507 const state: ThinkingWorkflowState = {
508 contextUpdated: false,
509 thinkingUpdated: false,
510 thinkingStaged: false,
511 contextUpdateCount: 0,
512 thinkingUpdateCount: 0,
513 requestedStage: null
514 }
515 let stagedThinkingMd: string | null = null
516
517 const finalizeStagedThinkingDocument = async (
518 options: { discardIncomplete?: boolean } = {}
519 ): Promise<StagedThinkingFinalizeResult> => {
520 if (!stagedThinkingMd) return { status: 'none' }
521
522 const completion = resolveStagedCompletion(stagedThinkingMd)
523 if (!completion.complete) {
524 if (options.discardIncomplete === false) {
525 return {
526 status: 'incomplete',
527 reason: completion.reason,
528 pageCount: completion.pageCount,
529 expectedPageCount: completion.expectedPageCount
530 }
531 }
532
533 stagedThinkingMd = null
534 state.thinkingStaged = false
535 return {
536 status: 'discarded',
537 reason: completion.reason,
538 pageCount: completion.pageCount,
539 expectedPageCount: completion.expectedPageCount
540 }
541 }
542
543 await writeThinkingMd(args.thinkingDir, stagedThinkingMd)
544 state.thinkingUpdated = true
545 state.thinkingStaged = false
546 state.thinkingUpdateCount += 1
547 const length = stagedThinkingMd.length
548 stagedThinkingMd = null
549 return {
550 status: 'committed',
551 length,
552 pageCount: completion.expectedPageCount || completion.pageCount
553 }
554 }
555
556 const updateContextDocument = tool(
557 async (input: ContextDocumentInput & { stage?: ThinkingStage }) => {
558 const requestedStage = input.stage
559 let stageNote = ''
560 if (requestedStage && isValidTransition(args.currentStage, requestedStage)) {
561 state.requestedStage = requestedStage
562 } else if (requestedStage) {
563 const validTargets = VALID_TRANSITIONS[args.currentStage].join(', ')
564 stageNote = ` Requested stage ${requestedStage} was ignored because it is not a valid transition from ${args.currentStage}. Valid targets: ${validTargets}.`
565 }
566 const contextPath = path.join(args.thinkingDir, 'context.md')
567 const existingContext = fs.existsSync(contextPath)
568 ? await fs.promises.readFile(contextPath, 'utf-8')
569 : buildContextMd({ stage: args.currentStage })
570 const content = mergeContextMd(existingContext, input, args.currentStage)
571 await writeContextMd(args.thinkingDir, content)
572 state.contextUpdated = true
573 state.contextUpdateCount += 1
574 return `context.md updated for stage ${args.currentStage}.${stageNote}`
575 },
576 {
577 name: 'update_context_document',
578 description:
579 'Required thinking workflow tool. Merge rolling conversation memory into /context.md every turn: user intent, confirmed decisions, open questions, source notes, and latest direction. Omitted fields are preserved; pass an empty array only when a list section should be cleared. Optionally set `stage` to transition to a new stage when the user explicitly requests it or requirements for the next stage are met. Use this instead of write_file/edit_file for context.md.',
580 schema: contextDocumentSchema
581 }
582 )
583
584 const updateThinkingDocument = tool(
585 async (input: ThinkingDocumentInput) => {
586 const isPageBatch = Boolean(input.pageStart && input.pageStart > 0)
587 if (isPageBatch) {
588 const pageStart = input.pageStart as number
589 const base = stagedThinkingMd ?? (await readExistingThinkingMd(args.thinkingDir))
590 stagedThinkingMd = await mergeThinkingMdFromBase(base, input)
591 state.thinkingStaged = true
592 if (!input.commit) {
593 return `thinking.md staged pages ${pageStart}-${pageStart + (input.pages?.length || 0) - 1}`
594 }
595 const result = await finalizeStagedThinkingDocument({ discardIncomplete: false })
596 if (result.status === 'committed') {
597 return `thinking.md updated from staged batches (${result.length} chars)`
598 }
599 if (result.status === 'incomplete') {
600 const expected = result.expectedPageCount
601 ? `${result.pageCount}/${result.expectedPageCount} pages staged`
602 : `${result.pageCount} pages staged`
603 return `thinking.md is still staged; ${result.reason} (${expected}). Continue submitting the missing page batches, then call update_thinking_document with commit=true again.`
604 }
605 return 'thinking.md is still staged; no staged document was ready to commit. Continue submitting page batches, then call update_thinking_document with commit=true again.'
606 }
607
608 const content = await mergeThinkingMd(args.thinkingDir, input)
609 await writeThinkingMd(args.thinkingDir, content)
610 state.thinkingUpdated = true
611 state.thinkingUpdateCount += 1
612 return 'thinking.md updated'
613 },
614 {
615 name: 'update_thinking_document',
616 description:
617 'Thinking document workflow tool. Merge updates into /thinking.md when the user asks for an outline, page plan, draft, style/font preference, or refined plan. Omit unchanged fields. Existing sections are preserved unless replaced. For large outlines, submit pages in batches: pass pageStart with 5-10 pages at a time. Batched calls are merged in memory and do not write thinking.md until the final batch sets commit=true. If pageStart is omitted, pages replaces all existing pages immediately. Every page must have a real title, role, objective, summary, and substantive keyPoints. Never write placeholder pages. Use this instead of write_file/edit_file for thinking.md.',
618 schema: thinkingDocumentSchema
619 }
620 )
621
622 return {
623 tools: [updateContextDocument, updateThinkingDocument],
624 state,
625 finalizeStagedThinkingDocument
626 }
627 }
628
628 lines TYPESCRIPT