@papyruslabsai/seshat-mcp 0.3.1 → 0.3.2

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.
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * Single-project mode (default):
14
14
  * When SESHAT_PROJECTS is not set, loads from CWD. No `project` param needed.
15
15
  *
16
- * Tools (8 core + 7 interpretation functors + 1 meta):
16
+ * Tools (8 core + 8 interpretation functors + 1 meta):
17
17
  * list_projects — Show loaded projects with entity counts
18
18
  * query_entities — Search entities by name, layer, module, language
19
19
  * get_entity — Full 9D coordinate dump for one entity
@@ -30,6 +30,7 @@
30
30
  * find_error_gaps — Fallible callees whose callers lack try/catch
31
31
  * get_test_coverage — Entities exercised by tests vs uncovered
32
32
  * get_optimal_context — Greedy knapsack: max relevance per token for LLM context
33
+ * estimate_task_cost — Pre-work token burn projection from blast radius + source tokens
33
34
  *
34
35
  * Usage:
35
36
  * npx @papyruslabs/seshat-mcp # single project (CWD)
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * Single-project mode (default):
14
14
  * When SESHAT_PROJECTS is not set, loads from CWD. No `project` param needed.
15
15
  *
16
- * Tools (8 core + 7 interpretation functors + 1 meta):
16
+ * Tools (8 core + 8 interpretation functors + 1 meta):
17
17
  * list_projects — Show loaded projects with entity counts
18
18
  * query_entities — Search entities by name, layer, module, language
19
19
  * get_entity — Full 9D coordinate dump for one entity
@@ -30,6 +30,7 @@
30
30
  * find_error_gaps — Fallible callees whose callers lack try/catch
31
31
  * get_test_coverage — Entities exercised by tests vs uncovered
32
32
  * get_optimal_context — Greedy knapsack: max relevance per token for LLM context
33
+ * estimate_task_cost — Pre-work token burn projection from blast radius + source tokens
33
34
  *
34
35
  * Usage:
35
36
  * npx @papyruslabs/seshat-mcp # single project (CWD)
@@ -43,7 +44,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
43
44
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
44
45
  import { MultiLoader } from './loader.js';
45
46
  import { initTools, queryEntities, getEntity, getDependencies, getDataFlow, findByConstraint, getBlastRadius, listModules, getTopology, } from './tools/index.js';
46
- import { findDeadCode, findLayerViolations, getCouplingMetrics, getAuthMatrix, findErrorGaps, getTestCoverage, getOptimalContext, } from './tools/functors.js';
47
+ import { findDeadCode, findLayerViolations, getCouplingMetrics, getAuthMatrix, findErrorGaps, getTestCoverage, getOptimalContext, estimateTaskCost, } from './tools/functors.js';
47
48
  // ─── Project Discovery ───────────────────────────────────────────
48
49
  /**
49
50
  * Discover project directories from SESHAT_PROJECTS env var.
@@ -337,6 +338,26 @@ const TOOLS = [
337
338
  required: ['target_entity'],
338
339
  },
339
340
  },
341
+ {
342
+ name: 'estimate_task_cost',
343
+ description: 'Estimate token cost of a code change BEFORE starting work. Computes blast radius, sums source token counts across affected entities, and projects total token burn including iteration cycles. Call this before planning to check if a task fits within your token budget.',
344
+ inputSchema: {
345
+ type: 'object',
346
+ properties: {
347
+ project: projectParam,
348
+ target_entities: {
349
+ type: 'array',
350
+ items: { type: 'string' },
351
+ description: 'Entity IDs or names that will be modified',
352
+ },
353
+ context_budget: {
354
+ type: 'number',
355
+ description: 'LLM context window token budget (default: 200000)',
356
+ },
357
+ },
358
+ required: ['target_entities'],
359
+ },
360
+ },
340
361
  ];
341
362
  // ─── Server Setup ─────────────────────────────────────────────────
342
363
  async function main() {
@@ -367,7 +388,7 @@ async function main() {
367
388
  }
368
389
  const server = new Server({
369
390
  name: serverLabel,
370
- version: '0.3.1',
391
+ version: '0.3.2',
371
392
  }, {
372
393
  capabilities: {
373
394
  tools: {},
@@ -451,6 +472,9 @@ async function main() {
451
472
  case 'get_optimal_context':
452
473
  result = getOptimalContext(args);
453
474
  break;
475
+ case 'estimate_task_cost':
476
+ result = estimateTaskCost(args);
477
+ break;
454
478
  default:
455
479
  result = { error: `Unknown tool: ${name}` };
456
480
  }
@@ -5,6 +5,13 @@
5
5
  * onto a domain-specific judgment. These are composite analyses built
6
6
  * from the primitive dimensions (sigma, epsilon, delta, kappa, chi, tau, rho).
7
7
  */
8
+ import type { JstfEntity } from '../types.js';
9
+ /**
10
+ * Estimate the token cost of loading an entity's source code into an LLM context.
11
+ * Uses real sourceTokens from the extraction pipeline when available (v0.3.2+),
12
+ * falls back to heuristic estimation from 9D coordinates for older bundles.
13
+ */
14
+ export declare function estimateTokens(e: JstfEntity): number;
8
15
  export declare function findDeadCode(args: {
9
16
  include_tests?: boolean;
10
17
  project?: string;
@@ -32,3 +39,12 @@ export declare function getOptimalContext(args: {
32
39
  strategy?: 'bfs' | 'blast_radius';
33
40
  project?: string;
34
41
  }): unknown;
42
+ /**
43
+ * Estimate token cost of a code change BEFORE starting work.
44
+ * Computes blast radius, sums source token counts, and projects total burn.
45
+ */
46
+ export declare function estimateTaskCost(args: {
47
+ target_entities: string[];
48
+ context_budget?: number;
49
+ project?: string;
50
+ }): unknown;
@@ -20,6 +20,35 @@ const LAYER_ORDER = {
20
20
  utility: 8,
21
21
  component: 1, // UI components are peers to controllers
22
22
  };
23
+ // ─── Shared: Token estimation ────────────────────────────────────
24
+ /**
25
+ * Estimate the token cost of loading an entity's source code into an LLM context.
26
+ * Uses real sourceTokens from the extraction pipeline when available (v0.3.2+),
27
+ * falls back to heuristic estimation from 9D coordinates for older bundles.
28
+ */
29
+ export function estimateTokens(e) {
30
+ // Use real source token count from extraction pipeline
31
+ const raw = e;
32
+ const st = raw.sourceTokens;
33
+ if (st?.estimated)
34
+ return st.estimated;
35
+ // Fallback: heuristic from 9D coordinates (pre-v0.3.2 bundles)
36
+ let tokens = 50; // Base: name, id, layer
37
+ if (e.struct && typeof e.struct !== 'string') {
38
+ tokens += 20; // signature
39
+ tokens += (e.struct.params?.length || 0) * 10;
40
+ }
41
+ if (e.edges?.calls)
42
+ tokens += e.edges.calls.length * 8;
43
+ if (e.edges?.imports)
44
+ tokens += e.edges.imports.length * 6;
45
+ if (e.data?.inputs)
46
+ tokens += e.data.inputs.length * 10;
47
+ if (e.constraints && typeof e.constraints === 'object' && !Array.isArray(e.constraints)) {
48
+ tokens += 30;
49
+ }
50
+ return tokens;
51
+ }
23
52
  // ─── Functor 1: find_dead_code ───────────────────────────────────
24
53
  export function findDeadCode(args) {
25
54
  const projErr = validateProject(args.project);
@@ -207,6 +236,7 @@ export function getCouplingMetrics(args) {
207
236
  cohesion: Math.round(cohesion * 1000) / 1000,
208
237
  coupling,
209
238
  instability: Math.round(instability * 1000) / 1000,
239
+ _summary: `${groupName}: ${internalEdges} internal / ${totalExternal} external edges, cohesion ${Math.round(cohesion * 1000) / 1000} (${size} entities)`,
210
240
  });
211
241
  }
212
242
  // Sort by coupling (most coupled first)
@@ -338,6 +368,7 @@ export function findErrorGaps(args) {
338
368
  return {
339
369
  totalFallible: fallibleIds.size,
340
370
  errorGaps: gaps.length,
371
+ _summary: `${gaps.length} error handling gaps across ${fallibleIds.size} fallible entities`,
341
372
  gaps: gaps.slice(0, 100),
342
373
  };
343
374
  }
@@ -384,14 +415,16 @@ export function getTestCoverage(args) {
384
415
  }
385
416
  const covered = productionEntities.filter(e => exercised.has(e.id));
386
417
  const uncovered = productionEntities.filter(e => !exercised.has(e.id));
418
+ const coveragePercent = productionEntities.length > 0
419
+ ? Math.round((covered.length / productionEntities.length) * 1000) / 10
420
+ : 0;
387
421
  const result = {
388
422
  totalProduction: productionEntities.length,
389
423
  totalTests: testIds.size,
390
424
  coveredCount: covered.length,
391
425
  uncoveredCount: uncovered.length,
392
- coveragePercent: productionEntities.length > 0
393
- ? Math.round((covered.length / productionEntities.length) * 1000) / 10
394
- : 0,
426
+ coveragePercent,
427
+ _summary: `${testIds.size} test entities exercise ${covered.length} of ${productionEntities.length} production entities (${coveragePercent}% coverage)`,
395
428
  };
396
429
  if (weight_by_blast_radius && uncovered.length > 0) {
397
430
  // Compute blast radius for each uncovered entity to prioritize what to test
@@ -431,24 +464,6 @@ export function getOptimalContext(args) {
431
464
  return { error: `Entity not found: ${target_entity}` };
432
465
  }
433
466
  const targetId = entity.id;
434
- // Estimate tokens for an entity based on its dimensions
435
- function estimateTokens(e) {
436
- let tokens = 50; // Base: name, id, layer
437
- if (e.struct && typeof e.struct !== 'string') {
438
- tokens += 20; // signature
439
- tokens += (e.struct.params?.length || 0) * 10;
440
- }
441
- if (e.edges?.calls)
442
- tokens += e.edges.calls.length * 8;
443
- if (e.edges?.imports)
444
- tokens += e.edges.imports.length * 6;
445
- if (e.data?.inputs)
446
- tokens += e.data.inputs.length * 10;
447
- if (e.constraints && typeof e.constraints === 'object' && !Array.isArray(e.constraints)) {
448
- tokens += 30;
449
- }
450
- return tokens;
451
- }
452
467
  const candidates = [];
453
468
  if (strategy === 'blast_radius') {
454
469
  // Use blast radius to get all related entities with depth
@@ -552,3 +567,138 @@ export function getOptimalContext(args) {
552
567
  context: selected,
553
568
  };
554
569
  }
570
+ // ─── Functor 8: estimate_task_cost ───────────────────────────────
571
+ /**
572
+ * Estimate token cost of a code change BEFORE starting work.
573
+ * Computes blast radius, sums source token counts, and projects total burn.
574
+ */
575
+ export function estimateTaskCost(args) {
576
+ const projErr = validateProject(args.project);
577
+ if (projErr)
578
+ return { error: projErr };
579
+ const { target_entities, context_budget = 200000 } = args;
580
+ const loader = getLoader();
581
+ const g = getGraph(args.project);
582
+ // Resolve target entities
583
+ const resolvedTargets = [];
584
+ const unresolvedNames = [];
585
+ const changedIds = new Set();
586
+ for (const name of target_entities) {
587
+ const entity = loader.getEntityById(name, args.project)
588
+ || loader.getEntityByName(name, args.project);
589
+ if (entity) {
590
+ resolvedTargets.push(entity);
591
+ changedIds.add(entity.id);
592
+ }
593
+ else {
594
+ unresolvedNames.push(name);
595
+ }
596
+ }
597
+ if (resolvedTargets.length === 0) {
598
+ return {
599
+ error: `No entities resolved. Unresolved: ${unresolvedNames.join(', ')}`,
600
+ hint: 'Use query_entities to find the correct entity IDs.',
601
+ };
602
+ }
603
+ // Compute blast radius across all targets
604
+ const br = computeBlastRadius(g, changedIds);
605
+ // Collect all affected entities (targets + blast radius)
606
+ const allAffectedIds = new Set([...changedIds, ...br.affected]);
607
+ const affectedEntities = [];
608
+ for (const id of allAffectedIds) {
609
+ const e = g.entityById.get(id);
610
+ if (e)
611
+ affectedEntities.push(e);
612
+ }
613
+ // Sum token estimates
614
+ let contextLoad = 0;
615
+ const fileTokens = new Map();
616
+ const layerSet = new Set();
617
+ for (const e of affectedEntities) {
618
+ const tokens = estimateTokens(e);
619
+ contextLoad += tokens;
620
+ const file = e._sourceFile || 'unknown';
621
+ if (!fileTokens.has(file))
622
+ fileTokens.set(file, { entities: 0, tokens: 0 });
623
+ const ft = fileTokens.get(file);
624
+ ft.entities++;
625
+ ft.tokens += tokens;
626
+ layerSet.add(entityLayer(e));
627
+ }
628
+ // Compute iteration multiplier
629
+ let iterationMultiplier = 1.5; // base: read + write pass
630
+ // +0.5 if cross-cutting (3+ layers)
631
+ if (layerSet.size >= 3)
632
+ iterationMultiplier += 0.5;
633
+ // +0.5 if any affected module has high instability
634
+ const affectedModules = new Set();
635
+ for (const e of affectedEntities) {
636
+ if (e.context?.module)
637
+ affectedModules.add(e.context.module);
638
+ }
639
+ // Quick instability check: compute outgoing / (outgoing + incoming) for affected modules
640
+ for (const mod of affectedModules) {
641
+ let outgoing = 0;
642
+ let incoming = 0;
643
+ const entities = loader.getEntities(args.project);
644
+ const modIds = new Set(entities.filter(e => e.context?.module === mod).map(e => e.id));
645
+ for (const id of modIds) {
646
+ const calleeSet = g.callees.get(id);
647
+ if (calleeSet) {
648
+ for (const cid of calleeSet) {
649
+ if (!modIds.has(cid))
650
+ outgoing++;
651
+ }
652
+ }
653
+ const callerSet = g.callers.get(id);
654
+ if (callerSet) {
655
+ for (const cid of callerSet) {
656
+ if (!modIds.has(cid))
657
+ incoming++;
658
+ }
659
+ }
660
+ }
661
+ const total = outgoing + incoming;
662
+ if (total > 0 && outgoing / total > 0.8) {
663
+ iterationMultiplier += 0.5;
664
+ break; // only add once
665
+ }
666
+ }
667
+ // +0.5 if large blast radius
668
+ if (allAffectedIds.size > 50)
669
+ iterationMultiplier += 0.5;
670
+ // Cap at 4.0
671
+ iterationMultiplier = Math.min(4.0, iterationMultiplier);
672
+ const projectedTotal = Math.round(contextLoad * iterationMultiplier);
673
+ const fitsInSinglePass = contextLoad <= context_budget;
674
+ const passesRequired = fitsInSinglePass ? 1 : Math.ceil(contextLoad / context_budget);
675
+ // Build file breakdown sorted by token count
676
+ const breakdown = [...fileTokens.entries()]
677
+ .map(([file, data]) => ({ file, entities: data.entities, tokens: data.tokens }))
678
+ .sort((a, b) => b.tokens - a.tokens);
679
+ // Build target summaries with token estimates
680
+ const targetSummaries = resolvedTargets.map(e => ({
681
+ ...entitySummary(e),
682
+ sourceTokens: estimateTokens(e),
683
+ }));
684
+ const affectedLayers = [...layerSet].sort();
685
+ return {
686
+ targets: targetSummaries,
687
+ ...(unresolvedNames.length > 0 ? { unresolved: unresolvedNames } : {}),
688
+ affectedEntities: allAffectedIds.size,
689
+ affectedFiles: fileTokens.size,
690
+ affectedLayers,
691
+ tokenEstimate: {
692
+ contextLoad,
693
+ iterationMultiplier: Math.round(iterationMultiplier * 10) / 10,
694
+ projectedTotal,
695
+ },
696
+ feasibility: {
697
+ contextBudget: context_budget,
698
+ fitsInSinglePass,
699
+ passesRequired,
700
+ },
701
+ breakdown: breakdown.slice(0, 30),
702
+ _summary: `Changing ${target_entities.join(', ')} affects ${allAffectedIds.size} entities across ${fileTokens.size} files. Context load: ~${Math.round(contextLoad / 1000)}K tokens. Projected total with ${Math.round(iterationMultiplier * 10) / 10}x iteration: ~${Math.round(projectedTotal / 1000)}K tokens. ${fitsInSinglePass ? `Fits in ${Math.round(context_budget / 1000)}K budget (${passesRequired} pass).` : `Exceeds ${Math.round(context_budget / 1000)}K budget — needs ${passesRequired} passes.`}`,
703
+ };
704
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papyruslabsai/seshat-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Semantic MCP server — exposes a codebase's 9D JSTF-T coordinate space as queryable tools",
5
5
  "type": "module",
6
6
  "bin": {