| 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 |