verifiable-thinking-mcp 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +339 -0
  3. package/package.json +75 -0
  4. package/src/index.ts +38 -0
  5. package/src/lib/cache.ts +246 -0
  6. package/src/lib/compression.ts +804 -0
  7. package/src/lib/compute/cache.ts +86 -0
  8. package/src/lib/compute/classifier.ts +555 -0
  9. package/src/lib/compute/confidence.ts +79 -0
  10. package/src/lib/compute/context.ts +154 -0
  11. package/src/lib/compute/extract.ts +200 -0
  12. package/src/lib/compute/filter.ts +224 -0
  13. package/src/lib/compute/index.ts +171 -0
  14. package/src/lib/compute/math.ts +247 -0
  15. package/src/lib/compute/patterns.ts +564 -0
  16. package/src/lib/compute/registry.ts +145 -0
  17. package/src/lib/compute/solvers/arithmetic.ts +65 -0
  18. package/src/lib/compute/solvers/calculus.ts +249 -0
  19. package/src/lib/compute/solvers/derivation-core.ts +371 -0
  20. package/src/lib/compute/solvers/derivation-latex.ts +160 -0
  21. package/src/lib/compute/solvers/derivation-mistakes.ts +1046 -0
  22. package/src/lib/compute/solvers/derivation-simplify.ts +451 -0
  23. package/src/lib/compute/solvers/derivation-transform.ts +620 -0
  24. package/src/lib/compute/solvers/derivation.ts +67 -0
  25. package/src/lib/compute/solvers/facts.ts +120 -0
  26. package/src/lib/compute/solvers/formula.ts +728 -0
  27. package/src/lib/compute/solvers/index.ts +36 -0
  28. package/src/lib/compute/solvers/logic.ts +422 -0
  29. package/src/lib/compute/solvers/probability.ts +307 -0
  30. package/src/lib/compute/solvers/statistics.ts +262 -0
  31. package/src/lib/compute/solvers/word-problems.ts +408 -0
  32. package/src/lib/compute/types.ts +107 -0
  33. package/src/lib/concepts.ts +111 -0
  34. package/src/lib/domain.ts +731 -0
  35. package/src/lib/extraction.ts +912 -0
  36. package/src/lib/index.ts +122 -0
  37. package/src/lib/judge.ts +260 -0
  38. package/src/lib/math/ast.ts +842 -0
  39. package/src/lib/math/index.ts +8 -0
  40. package/src/lib/math/operators.ts +171 -0
  41. package/src/lib/math/tokenizer.ts +477 -0
  42. package/src/lib/patterns.ts +200 -0
  43. package/src/lib/session.ts +825 -0
  44. package/src/lib/think/challenge.ts +323 -0
  45. package/src/lib/think/complexity.ts +504 -0
  46. package/src/lib/think/confidence-drift.ts +507 -0
  47. package/src/lib/think/consistency.ts +347 -0
  48. package/src/lib/think/guidance.ts +188 -0
  49. package/src/lib/think/helpers.ts +568 -0
  50. package/src/lib/think/hypothesis.ts +216 -0
  51. package/src/lib/think/index.ts +127 -0
  52. package/src/lib/think/prompts.ts +262 -0
  53. package/src/lib/think/route.ts +358 -0
  54. package/src/lib/think/schema.ts +98 -0
  55. package/src/lib/think/scratchpad-schema.ts +662 -0
  56. package/src/lib/think/spot-check.ts +961 -0
  57. package/src/lib/think/types.ts +93 -0
  58. package/src/lib/think/verification.ts +260 -0
  59. package/src/lib/tokens.ts +177 -0
  60. package/src/lib/verification.ts +620 -0
  61. package/src/prompts/index.ts +10 -0
  62. package/src/prompts/templates.ts +336 -0
  63. package/src/resources/index.ts +8 -0
  64. package/src/resources/sessions.ts +196 -0
  65. package/src/tools/compress.ts +138 -0
  66. package/src/tools/index.ts +5 -0
  67. package/src/tools/scratchpad.ts +2659 -0
  68. package/src/tools/sessions.ts +144 -0
@@ -0,0 +1,825 @@
1
+ /**
2
+ * Session Manager - High-performance session state with O(1) lookups
3
+ * Features:
4
+ * - O(1) step lookup via stepIndex Map
5
+ * - O(1) step existence check via stepNumbers Set
6
+ * - O(1) step-to-branch lookup via stepToBranchMap
7
+ * - Revision tracking with revised_by marker
8
+ * - Branch depth limits
9
+ * - Batched TTL cleanup for efficiency
10
+ * - Token tracking sync on cleanup
11
+ */
12
+
13
+ import { clearSessionTokens } from "./tokens.ts";
14
+
15
+ export interface ThoughtRecord {
16
+ id: string;
17
+ step_number: number;
18
+ thought: string;
19
+ timestamp: number;
20
+ branch_id: string;
21
+ verification?: {
22
+ passed: boolean;
23
+ confidence: number;
24
+ domain: string;
25
+ };
26
+ concepts?: string[];
27
+ compressed_context?: string;
28
+ // Compression stats
29
+ compression?: {
30
+ input_bytes_saved: number;
31
+ output_bytes_saved: number;
32
+ context_bytes_saved: number;
33
+ // Token tracking for LLM budget planning
34
+ original_tokens?: number;
35
+ compressed_tokens?: number;
36
+ };
37
+ // Revision tracking
38
+ revises_step?: number;
39
+ revision_reason?: string;
40
+ revised_by?: number; // Step number that revised this step
41
+ // Branching
42
+ branch_from?: number;
43
+ branch_name?: string;
44
+ branch_depth?: number;
45
+ // Dependencies
46
+ dependencies?: number[];
47
+ // Tool tracking
48
+ tools_used?: string[];
49
+ external_context?: Record<string, unknown>;
50
+ // Hypothesis-driven branching
51
+ hypothesis?: string;
52
+ success_criteria?: string;
53
+ // Preconditions (assumptions that must be true for this step)
54
+ preconditions?: string[];
55
+ }
56
+
57
+ export interface Branch {
58
+ id: string;
59
+ name: string;
60
+ from_step: number;
61
+ depth: number;
62
+ created_at: number;
63
+ /** Falsifiable hypothesis this branch tests */
64
+ hypothesis?: string;
65
+ /** Criteria for proving/disproving the hypothesis */
66
+ success_criteria?: string;
67
+ }
68
+
69
+ export interface Session {
70
+ id: string;
71
+ created_at: number;
72
+ updated_at: number;
73
+ thoughts: ThoughtRecord[];
74
+ branches: Map<string, Branch>;
75
+ metadata: Record<string, unknown>;
76
+ // O(1) lookup indexes
77
+ stepIndex: Map<number, ThoughtRecord>;
78
+ stepNumbers: Set<number>;
79
+ stepToBranchMap: Map<number, string>;
80
+ toolsUsedSet: Set<string>;
81
+ // Original question (for auto spot-check at complete)
82
+ question?: string;
83
+ // Pending thought that failed verification (awaiting recovery action)
84
+ pendingThought?: {
85
+ thought: ThoughtRecord;
86
+ verificationError: {
87
+ issue: string;
88
+ evidence: string;
89
+ suggestions: string[];
90
+ confidence: number;
91
+ domain: string;
92
+ };
93
+ };
94
+ }
95
+
96
+ interface SessionManagerConfig {
97
+ ttl_ms: number;
98
+ cleanup_interval_ms: number;
99
+ max_sessions: number;
100
+ max_branch_depth: number;
101
+ max_history_size: number;
102
+ cleanup_batch_size: number; // Steps between cleanup checks
103
+ }
104
+
105
+ const DEFAULT_CONFIG: SessionManagerConfig = {
106
+ ttl_ms: 30 * 60 * 1000, // 30 minutes
107
+ cleanup_interval_ms: 5 * 60 * 1000, // 5 minutes
108
+ max_sessions: 100,
109
+ max_branch_depth: 3,
110
+ max_history_size: 100,
111
+ cleanup_batch_size: 10, // Check cleanup every 10 steps
112
+ };
113
+
114
+ /** Max pooled sessions to keep for reuse (avoids GC pressure from Map/Set allocations) */
115
+ const MAX_POOL_SIZE = 20;
116
+
117
+ class SessionManagerImpl {
118
+ private sessions: Map<string, Session> = new Map();
119
+ private config: SessionManagerConfig;
120
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null;
121
+ private stepsSinceCleanup = 0;
122
+ /** Pool of recycled sessions to avoid allocation churn */
123
+ private sessionPool: Session[] = [];
124
+
125
+ constructor(config: Partial<SessionManagerConfig> = {}) {
126
+ this.config = { ...DEFAULT_CONFIG, ...config };
127
+ this.startCleanup();
128
+ }
129
+
130
+ private startCleanup(): void {
131
+ if (this.cleanupTimer) return;
132
+ this.cleanupTimer = setInterval(() => {
133
+ this.cleanup();
134
+ }, this.config.cleanup_interval_ms);
135
+ }
136
+
137
+ /**
138
+ * Recycle a session by clearing its data structures without deallocating.
139
+ * This avoids GC pressure from repeatedly creating new Map/Set instances.
140
+ */
141
+ private recycleSession(session: Session): void {
142
+ if (this.sessionPool.length >= MAX_POOL_SIZE) {
143
+ // Pool full, let GC handle it
144
+ return;
145
+ }
146
+
147
+ // Clear data structures without deallocating
148
+ session.thoughts.length = 0;
149
+ session.branches.clear();
150
+ session.stepIndex.clear();
151
+ session.stepNumbers.clear();
152
+ session.stepToBranchMap.clear();
153
+ session.toolsUsedSet.clear();
154
+ session.metadata = {};
155
+ session.question = undefined;
156
+ session.pendingThought = undefined;
157
+
158
+ this.sessionPool.push(session);
159
+ }
160
+
161
+ /**
162
+ * Get a pooled session if available, otherwise create new.
163
+ * Reusing sessions avoids Map/Set allocation overhead.
164
+ */
165
+ private getPooledSession(sessionId: string): Session {
166
+ const pooled = this.sessionPool.pop();
167
+ if (pooled) {
168
+ // Reinitialize pooled session with new id
169
+ pooled.id = sessionId;
170
+ pooled.created_at = Date.now();
171
+ pooled.updated_at = Date.now();
172
+ pooled.branches.set("main", {
173
+ id: "main",
174
+ name: "Main",
175
+ from_step: 0,
176
+ depth: 0,
177
+ created_at: Date.now(),
178
+ });
179
+ return pooled;
180
+ }
181
+
182
+ // No pooled session available, create new
183
+ return this.createSession(sessionId);
184
+ }
185
+
186
+ private cleanup(): void {
187
+ const now = Date.now();
188
+ const expired: string[] = [];
189
+
190
+ for (const [id, session] of this.sessions) {
191
+ if (now - session.updated_at > this.config.ttl_ms) {
192
+ expired.push(id);
193
+ }
194
+ }
195
+
196
+ for (const id of expired) {
197
+ // Clear token tracking FIRST (before session deletion and recycling)
198
+ clearSessionTokens(id);
199
+
200
+ const session = this.sessions.get(id);
201
+ // Delete from map BEFORE recycling to prevent use-after-free race
202
+ this.sessions.delete(id);
203
+ if (session) {
204
+ this.recycleSession(session);
205
+ }
206
+ }
207
+ }
208
+
209
+ /** Batched cleanup - only runs every N steps */
210
+ private batchedCleanup(force = false): void {
211
+ this.stepsSinceCleanup++;
212
+ if (!force && this.stepsSinceCleanup < this.config.cleanup_batch_size) {
213
+ return;
214
+ }
215
+ this.stepsSinceCleanup = 0;
216
+ this.cleanup();
217
+ }
218
+
219
+ private createSession(sessionId: string): Session {
220
+ return {
221
+ id: sessionId,
222
+ created_at: Date.now(),
223
+ updated_at: Date.now(),
224
+ thoughts: [],
225
+ branches: new Map([
226
+ ["main", { id: "main", name: "Main", from_step: 0, depth: 0, created_at: Date.now() }],
227
+ ]),
228
+ metadata: {},
229
+ stepIndex: new Map(),
230
+ stepNumbers: new Set(),
231
+ stepToBranchMap: new Map(),
232
+ toolsUsedSet: new Set(),
233
+ };
234
+ }
235
+
236
+ getOrCreate(sessionId: string): Session {
237
+ let session = this.sessions.get(sessionId);
238
+
239
+ if (!session) {
240
+ // Enforce max sessions limit
241
+ if (this.sessions.size >= this.config.max_sessions) {
242
+ let oldest: [string, Session] | null = null;
243
+ for (const entry of this.sessions) {
244
+ if (!oldest || entry[1].updated_at < oldest[1].updated_at) {
245
+ oldest = entry;
246
+ }
247
+ }
248
+ if (oldest) {
249
+ const oldSession = this.sessions.get(oldest[0]);
250
+ if (oldSession) {
251
+ this.recycleSession(oldSession);
252
+ }
253
+ this.sessions.delete(oldest[0]);
254
+ }
255
+ }
256
+
257
+ session = this.getPooledSession(sessionId);
258
+ this.sessions.set(sessionId, session);
259
+ }
260
+
261
+ return session;
262
+ }
263
+
264
+ get(sessionId: string): Session | undefined {
265
+ const session = this.sessions.get(sessionId);
266
+ if (session) {
267
+ session.updated_at = Date.now();
268
+ }
269
+ return session;
270
+ }
271
+
272
+ /** O(1) step lookup */
273
+ getStep(sessionId: string, stepNumber: number): ThoughtRecord | undefined {
274
+ const session = this.get(sessionId);
275
+ return session?.stepIndex.get(stepNumber);
276
+ }
277
+
278
+ /** O(1) step existence check */
279
+ hasStep(sessionId: string, stepNumber: number): boolean {
280
+ const session = this.get(sessionId);
281
+ return session?.stepNumbers.has(stepNumber) ?? false;
282
+ }
283
+
284
+ /** Calculate branch depth for a step */
285
+ calculateBranchDepth(session: Session, fromStep: number): number {
286
+ const branchId = session.stepToBranchMap.get(fromStep);
287
+ if (branchId) {
288
+ const branch = session.branches.get(branchId);
289
+ return branch ? branch.depth + 1 : 1;
290
+ }
291
+ return 1; // Branching from main history
292
+ }
293
+
294
+ /** Add thought with O(1) index updates */
295
+ addThought(sessionId: string, thought: ThoughtRecord): { success: boolean; error?: string } {
296
+ const session = this.getOrCreate(sessionId);
297
+
298
+ // Batched cleanup
299
+ this.batchedCleanup();
300
+
301
+ // Validate revision
302
+ if (thought.revises_step !== undefined) {
303
+ if (thought.revises_step >= thought.step_number) {
304
+ return {
305
+ success: false,
306
+ error: `Cannot revise step ${thought.revises_step} from step ${thought.step_number}`,
307
+ };
308
+ }
309
+ // Mark original step as revised
310
+ const original = session.stepIndex.get(thought.revises_step);
311
+ if (original) {
312
+ original.revised_by = thought.step_number;
313
+ }
314
+ }
315
+
316
+ // Handle branching
317
+ if (thought.branch_from !== undefined) {
318
+ if (thought.branch_from >= thought.step_number) {
319
+ return { success: false, error: `Cannot branch from future step ${thought.branch_from}` };
320
+ }
321
+ if (!session.stepNumbers.has(thought.branch_from)) {
322
+ return {
323
+ success: false,
324
+ error: `Cannot branch from non-existent step ${thought.branch_from}`,
325
+ };
326
+ }
327
+
328
+ const depth = this.calculateBranchDepth(session, thought.branch_from);
329
+ if (depth > this.config.max_branch_depth) {
330
+ return {
331
+ success: false,
332
+ error: `Branch depth ${depth} exceeds max ${this.config.max_branch_depth}`,
333
+ };
334
+ }
335
+
336
+ const branchId = thought.branch_id || `branch-${Date.now()}`;
337
+ if (!session.branches.has(branchId)) {
338
+ session.branches.set(branchId, {
339
+ id: branchId,
340
+ name: thought.branch_name || `Alternative ${session.branches.size}`,
341
+ from_step: thought.branch_from,
342
+ depth,
343
+ created_at: Date.now(),
344
+ hypothesis: thought.hypothesis,
345
+ success_criteria: thought.success_criteria,
346
+ });
347
+ }
348
+ thought.branch_id = branchId;
349
+ thought.branch_depth = depth;
350
+ }
351
+
352
+ // Validate dependencies
353
+ if (thought.dependencies?.length) {
354
+ const missing = thought.dependencies.filter((d) => !session.stepNumbers.has(d));
355
+ if (missing.length > 0) {
356
+ // Warn but don't fail
357
+ console.error(`Warning: Missing dependencies: steps ${missing.join(", ")}`);
358
+ }
359
+ // Check for circular/future dependencies
360
+ const invalid = thought.dependencies.filter((d) => d >= thought.step_number);
361
+ if (invalid.length > 0) {
362
+ return { success: false, error: `Cannot depend on future steps: ${invalid.join(", ")}` };
363
+ }
364
+ }
365
+
366
+ // Track tools used
367
+ if (thought.tools_used?.length) {
368
+ for (const tool of thought.tools_used) {
369
+ session.toolsUsedSet.add(tool);
370
+ }
371
+ }
372
+
373
+ // Add to history and update indexes
374
+ session.thoughts.push(thought);
375
+ session.stepIndex.set(thought.step_number, thought);
376
+ session.stepNumbers.add(thought.step_number);
377
+
378
+ const branchId = thought.branch_id || "main";
379
+ session.stepToBranchMap.set(thought.step_number, branchId);
380
+
381
+ // Ensure branch exists
382
+ if (!session.branches.has(branchId)) {
383
+ session.branches.set(branchId, {
384
+ id: branchId,
385
+ name: branchId === "main" ? "Main" : branchId,
386
+ from_step: 0,
387
+ depth: 0,
388
+ created_at: Date.now(),
389
+ });
390
+ }
391
+
392
+ session.updated_at = Date.now();
393
+
394
+ // Trim history if needed
395
+ this.trimHistory(session);
396
+
397
+ return { success: true };
398
+ }
399
+
400
+ /** Trim history and clean up orphaned references */
401
+ private trimHistory(session: Session): void {
402
+ if (session.thoughts.length <= this.config.max_history_size) {
403
+ return;
404
+ }
405
+
406
+ const toRemove = session.thoughts.length - this.config.max_history_size;
407
+ const removed = session.thoughts.splice(0, toRemove);
408
+
409
+ // Clean up indexes
410
+ for (const thought of removed) {
411
+ session.stepIndex.delete(thought.step_number);
412
+ session.stepNumbers.delete(thought.step_number);
413
+ session.stepToBranchMap.delete(thought.step_number);
414
+ }
415
+
416
+ // Clean up branches that reference removed steps
417
+ const removedStepNumbers = new Set(removed.map((t) => t.step_number));
418
+ for (const [branchId, branch] of session.branches) {
419
+ if (removedStepNumbers.has(branch.from_step) && branchId !== "main") {
420
+ session.branches.delete(branchId);
421
+ }
422
+ }
423
+ }
424
+
425
+ getThoughts(sessionId: string, branchId?: string): ThoughtRecord[] {
426
+ const session = this.get(sessionId);
427
+ if (!session) return [];
428
+
429
+ if (branchId) {
430
+ return session.thoughts.filter((t) => t.branch_id === branchId);
431
+ }
432
+ return session.thoughts;
433
+ }
434
+
435
+ getBranches(sessionId: string): Branch[] {
436
+ const session = this.get(sessionId);
437
+ return session ? Array.from(session.branches.values()) : [];
438
+ }
439
+
440
+ list(): { id: string; thought_count: number; branches: string[]; age_ms: number }[] {
441
+ const now = Date.now();
442
+ return Array.from(this.sessions.values()).map((s) => ({
443
+ id: s.id,
444
+ thought_count: s.thoughts.length,
445
+ branches: Array.from(s.branches.keys()),
446
+ age_ms: now - s.created_at,
447
+ }));
448
+ }
449
+
450
+ clear(sessionId: string): boolean {
451
+ // Clear token tracking when session is explicitly cleared
452
+ clearSessionTokens(sessionId);
453
+ return this.sessions.delete(sessionId);
454
+ }
455
+
456
+ clearAll(): number {
457
+ const count = this.sessions.size;
458
+ // Clear token tracking for all sessions
459
+ for (const id of this.sessions.keys()) {
460
+ clearSessionTokens(id);
461
+ }
462
+ this.sessions.clear();
463
+ return count;
464
+ }
465
+
466
+ getSummary(sessionId: string): string | null {
467
+ const session = this.get(sessionId);
468
+ if (!session) return null;
469
+
470
+ // Aggregate compression stats
471
+ let totalInputSaved = 0;
472
+ let totalOutputSaved = 0;
473
+ let totalContextSaved = 0;
474
+ let compressedSteps = 0;
475
+
476
+ for (const thought of session.thoughts) {
477
+ if (thought.compression) {
478
+ compressedSteps++;
479
+ totalInputSaved += thought.compression.input_bytes_saved;
480
+ totalOutputSaved += thought.compression.output_bytes_saved;
481
+ totalContextSaved += thought.compression.context_bytes_saved;
482
+ }
483
+ }
484
+
485
+ const totalBytesSaved = totalInputSaved + totalOutputSaved + totalContextSaved;
486
+
487
+ const lines: string[] = [
488
+ `Session: ${sessionId}`,
489
+ `Thoughts: ${session.thoughts.length}`,
490
+ `Branches: ${Array.from(session.branches.keys()).join(", ")}`,
491
+ `Tools Used: ${Array.from(session.toolsUsedSet).join(", ") || "none"}`,
492
+ ];
493
+
494
+ // Add compression summary if any compression occurred
495
+ if (compressedSteps > 0) {
496
+ lines.push(
497
+ `Compression: ${compressedSteps} steps, ${totalBytesSaved} bytes saved (input: ${totalInputSaved}, output: ${totalOutputSaved}, context: ${totalContextSaved})`,
498
+ );
499
+ }
500
+
501
+ lines.push("");
502
+
503
+ for (const thought of session.thoughts) {
504
+ const v = thought.verification;
505
+ const status = v ? (v.passed ? "+" : "x") : "?";
506
+ const revised = thought.revised_by ? ` [revised by ${thought.revised_by}]` : "";
507
+ const revising = thought.revises_step ? ` [revises ${thought.revises_step}]` : "";
508
+ lines.push(
509
+ `[${status}] Step ${thought.step_number} (${thought.branch_id})${revised}${revising}: ${thought.thought.slice(0, 60)}...`,
510
+ );
511
+ }
512
+
513
+ return lines.join("\n");
514
+ }
515
+
516
+ getCompressed(sessionId: string): string | null {
517
+ const session = this.get(sessionId);
518
+ if (!session) return null;
519
+
520
+ // Return only key thoughts (verified, final, or not revised)
521
+ const key = session.thoughts.filter(
522
+ (t) =>
523
+ !t.revised_by && // Not superseded
524
+ (t.verification?.passed ||
525
+ t.step_number === Math.max(...session.thoughts.map((x) => x.step_number))),
526
+ );
527
+
528
+ return key.map((t) => t.thought).join(" -> ");
529
+ }
530
+
531
+ /** Get revision chain for a step */
532
+ getRevisionChain(sessionId: string, stepNumber: number): ThoughtRecord[] {
533
+ const session = this.get(sessionId);
534
+ if (!session) return [];
535
+
536
+ const chain: ThoughtRecord[] = [];
537
+ let current = session.stepIndex.get(stepNumber);
538
+
539
+ while (current) {
540
+ chain.push(current);
541
+ if (current.revised_by) {
542
+ current = session.stepIndex.get(current.revised_by);
543
+ } else {
544
+ break;
545
+ }
546
+ }
547
+
548
+ return chain;
549
+ }
550
+
551
+ /** Get compression stats for a session */
552
+ getCompressionStats(sessionId: string): {
553
+ totalBytesSaved: number;
554
+ stepCount: number;
555
+ breakdown: { input: number; output: number; context: number };
556
+ tokens: { original: number; compressed: number; saved: number };
557
+ } | null {
558
+ const session = this.get(sessionId);
559
+ if (!session) return null;
560
+
561
+ let totalInput = 0;
562
+ let totalOutput = 0;
563
+ let totalContext = 0;
564
+ let totalOriginalTokens = 0;
565
+ let totalCompressedTokens = 0;
566
+ let stepCount = 0;
567
+
568
+ for (const thought of session.thoughts) {
569
+ if (thought.compression) {
570
+ stepCount++;
571
+ totalInput += thought.compression.input_bytes_saved;
572
+ totalOutput += thought.compression.output_bytes_saved;
573
+ totalContext += thought.compression.context_bytes_saved;
574
+ totalOriginalTokens += thought.compression.original_tokens || 0;
575
+ totalCompressedTokens += thought.compression.compressed_tokens || 0;
576
+ }
577
+ }
578
+
579
+ return {
580
+ totalBytesSaved: totalInput + totalOutput + totalContext,
581
+ stepCount,
582
+ breakdown: { input: totalInput, output: totalOutput, context: totalContext },
583
+ tokens: {
584
+ original: totalOriginalTokens,
585
+ compressed: totalCompressedTokens,
586
+ saved: totalOriginalTokens - totalCompressedTokens,
587
+ },
588
+ };
589
+ }
590
+
591
+ /** Get path from root to a step (ancestors) - O(n) where n = path length */
592
+ getPath(sessionId: string, stepNumber: number): ThoughtRecord[] {
593
+ const session = this.get(sessionId);
594
+ if (!session) return [];
595
+
596
+ const path: ThoughtRecord[] = [];
597
+ let current = session.stepIndex.get(stepNumber);
598
+
599
+ while (current) {
600
+ path.unshift(current); // Add to front to get root-first order
601
+
602
+ // Walk back via branch_from or revision chain
603
+ if (current.branch_from !== undefined) {
604
+ current = session.stepIndex.get(current.branch_from);
605
+ } else if (current.revises_step !== undefined) {
606
+ // Skip to the original step being revised
607
+ current = session.stepIndex.get(current.revises_step);
608
+ } else if (current.step_number > 1) {
609
+ // Linear predecessor in same branch
610
+ const prevStep = current.step_number - 1;
611
+ const prev = session.stepIndex.get(prevStep);
612
+ // Only follow if same branch
613
+ if (prev && prev.branch_id === current.branch_id) {
614
+ current = prev;
615
+ } else {
616
+ break;
617
+ }
618
+ } else {
619
+ break;
620
+ }
621
+ }
622
+
623
+ return path;
624
+ }
625
+
626
+ /** Get current step number for a session/branch */
627
+ getCurrentStep(sessionId: string, branchId = "main"): number {
628
+ const session = this.get(sessionId);
629
+ if (!session) return 0;
630
+
631
+ const branchThoughts = session.thoughts.filter((t) => t.branch_id === branchId);
632
+ if (branchThoughts.length === 0) return 0;
633
+
634
+ return Math.max(...branchThoughts.map((t) => t.step_number));
635
+ }
636
+
637
+ /** Get next step number for a session/branch */
638
+ getNextStep(sessionId: string, branchId = "main"): number {
639
+ return this.getCurrentStep(sessionId, branchId) + 1;
640
+ }
641
+
642
+ /** Calculate average confidence across session */
643
+ getAverageConfidence(sessionId: string, branchId?: string): number {
644
+ const session = this.get(sessionId);
645
+ if (!session) return 0;
646
+
647
+ const thoughts = branchId
648
+ ? session.thoughts.filter((t) => t.branch_id === branchId)
649
+ : session.thoughts;
650
+
651
+ if (thoughts.length === 0) return 0;
652
+
653
+ const confidences = thoughts
654
+ .filter((t) => t.verification?.confidence !== undefined)
655
+ .map((t) => t.verification!.confidence);
656
+
657
+ if (confidences.length === 0) return 0;
658
+
659
+ return confidences.reduce((a, b) => a + b, 0) / confidences.length;
660
+ }
661
+
662
+ /** Get total token usage for a session (estimated from thought lengths) */
663
+ getTokenUsage(sessionId: string): {
664
+ total: number;
665
+ compressed: number;
666
+ uncompressed: number;
667
+ } {
668
+ const session = this.get(sessionId);
669
+ if (!session) return { total: 0, compressed: 0, uncompressed: 0 };
670
+
671
+ let compressed = 0;
672
+ let uncompressed = 0;
673
+
674
+ for (const thought of session.thoughts) {
675
+ // Estimate tokens: ~4 chars per token
676
+ const thoughtTokens = Math.ceil(thought.thought.length / 4);
677
+
678
+ if (thought.compression?.compressed_tokens !== undefined) {
679
+ compressed += thought.compression.compressed_tokens;
680
+ } else {
681
+ uncompressed += thoughtTokens;
682
+ }
683
+ }
684
+
685
+ return {
686
+ total: compressed + uncompressed,
687
+ compressed,
688
+ uncompressed,
689
+ };
690
+ }
691
+
692
+ /** Store a pending thought that failed verification */
693
+ setPendingThought(
694
+ sessionId: string,
695
+ thought: ThoughtRecord,
696
+ verificationError: {
697
+ issue: string;
698
+ evidence: string;
699
+ suggestions: string[];
700
+ confidence: number;
701
+ domain: string;
702
+ },
703
+ ): void {
704
+ const session = this.getOrCreate(sessionId);
705
+ session.pendingThought = { thought, verificationError };
706
+ session.updated_at = Date.now();
707
+ }
708
+
709
+ /** Get pending thought for a session */
710
+ getPendingThought(sessionId: string): Session["pendingThought"] {
711
+ const session = this.get(sessionId);
712
+ return session?.pendingThought;
713
+ }
714
+
715
+ /** Clear pending thought (after override or when replaced by revision) */
716
+ clearPendingThought(sessionId: string): boolean {
717
+ const session = this.get(sessionId);
718
+ if (!session?.pendingThought) return false;
719
+ session.pendingThought = undefined;
720
+ session.updated_at = Date.now();
721
+ return true;
722
+ }
723
+
724
+ /** Commit pending thought (used by override operation)
725
+ * Atomic: only clears pending if addThought succeeds
726
+ */
727
+ commitPendingThought(sessionId: string): { success: boolean; error?: string } {
728
+ const session = this.get(sessionId);
729
+ if (!session?.pendingThought) {
730
+ return { success: false, error: "No pending thought to commit" };
731
+ }
732
+
733
+ // Clone the pending thought before attempting to add
734
+ // This ensures we can restore if addThought fails
735
+ const pendingCopy = session.pendingThought;
736
+
737
+ const result = this.addThought(sessionId, pendingCopy.thought);
738
+ if (result.success) {
739
+ // Only clear pending if addThought succeeded
740
+ session.pendingThought = undefined;
741
+ }
742
+ // If addThought failed, pending is preserved for retry/alternate recovery
743
+ return result;
744
+ }
745
+
746
+ /** Store hint state for progressive reveals */
747
+ setHintState(
748
+ sessionId: string,
749
+ state: {
750
+ expression: string;
751
+ revealCount: number;
752
+ totalSteps: number;
753
+ simplified: string;
754
+ },
755
+ ): void {
756
+ const session = this.getOrCreate(sessionId);
757
+ session.metadata.hintState = state;
758
+ session.updated_at = Date.now();
759
+ }
760
+
761
+ /** Get hint state for a session */
762
+ getHintState(sessionId: string): {
763
+ expression: string;
764
+ revealCount: number;
765
+ totalSteps: number;
766
+ simplified: string;
767
+ } | null {
768
+ const session = this.get(sessionId);
769
+ const state = session?.metadata?.hintState;
770
+ if (
771
+ state &&
772
+ typeof state === "object" &&
773
+ "expression" in state &&
774
+ "revealCount" in state &&
775
+ "totalSteps" in state &&
776
+ "simplified" in state
777
+ ) {
778
+ return state as {
779
+ expression: string;
780
+ revealCount: number;
781
+ totalSteps: number;
782
+ simplified: string;
783
+ };
784
+ }
785
+ return null;
786
+ }
787
+
788
+ /** Clear hint state */
789
+ clearHintState(sessionId: string): void {
790
+ const session = this.get(sessionId);
791
+ if (session?.metadata) {
792
+ delete session.metadata.hintState;
793
+ session.updated_at = Date.now();
794
+ }
795
+ }
796
+
797
+ /** Store the original question for a session (for auto spot-check at complete) */
798
+ setQuestion(sessionId: string, question: string): void {
799
+ const session = this.getOrCreate(sessionId);
800
+ // First-write-wins: don't overwrite existing question (prevents race condition)
801
+ if (session.question) return;
802
+ session.question = question;
803
+ session.updated_at = Date.now();
804
+ }
805
+
806
+ /** Get the stored question for a session */
807
+ getQuestion(sessionId: string): string | undefined {
808
+ const session = this.get(sessionId);
809
+ return session?.question;
810
+ }
811
+
812
+ destroy(): void {
813
+ if (this.cleanupTimer) {
814
+ clearInterval(this.cleanupTimer);
815
+ this.cleanupTimer = null;
816
+ }
817
+ this.sessions.clear();
818
+ }
819
+ }
820
+
821
+ // Export class for testing
822
+ export { SessionManagerImpl };
823
+
824
+ // Singleton instance
825
+ export const SessionManager = new SessionManagerImpl();